DeltaChip
The percentage change beside a number. The arrow reports direction, the colour reports sentiment, and the two come apart for metrics where falling is the win.
Install
$ npx shadcn@latest add wisp.pouriah.com/r/delta-chip.jsonStat · DOMsemantic
Revenueup 12.4%
Signupsup 4.8%
p95 latencydown 18.2%
Error rateup 3.1%
Churndown 6.5%
arrow = direction · colour = sentiment"use client";
import * as React from "react";
import { cx } from "@pouriahlabs/wisp-ui";
/**
* DeltaChip — the percentage change that sits next to a number.
*
* Standalone on purpose. This started life inside `StatTile`, which is exactly
* where nobody could use it: the chip belongs in table cells, on list rows and
* beside inline figures at least as often as it belongs on a metric card.
*
* Two things it gets right that hand-rolled chips usually don't.
*
* **Direction and sentiment are separate.** The arrow reports which way the
* number moved; the colour reports whether that is good. For latency, cost,
* churn or error rate a fall is the win — `invert` flips the colour and leaves
* the arrow alone, so a reader is never told that a dropping error rate is bad.
*
* **The arrow is not the information.** `▲` is a decorative glyph: depending on
* the screen reader it is announced as "black up-pointing triangle", read as
* nothing at all, or spelled out mid-sentence. So it is `aria-hidden` and the
* direction is carried by a visually hidden word, which is what actually gets
* announced — "up 12.4 percent", not "triangle 12.4".
*
* The colours are semantic tokens — `--wisp-up` / `--wisp-down` — deliberately
* separate from the brand accent. A falling number is negative, not "on brand",
* and reusing the accent for it means a reader cannot tell emphasis from
* direction.
*
* Exactly zero is its own state, not a rounding error folded into "up" — a
* flat metric read as "up 0.0%" tells the reader something improved when
* nothing did. It only applies when `direction` isn't forced, since a forced
* direction is deliberately overriding what `value`'s sign would otherwise say.
*/
export interface DeltaChipProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, "children"> {
/** The change. Its magnitude is displayed; its sign picks the arrow. */
value: number;
/**
* Force the direction, ignoring the sign of `value`. Use it when `value` is
* animating up from zero — otherwise a falling metric shows an up arrow for
* the first frame of the count and flips, which reads as a glitch.
*/
direction?: 1 | -1;
decimals?: number;
/** Unit appended to the magnitude. */
suffix?: string;
/** A fall is the good outcome here: latency, cost, churn, error rate. */
invert?: boolean;
/** Drop the arrow and keep the colour. */
arrow?: boolean;
}
export function DeltaChip({
value,
direction,
decimals = 1,
suffix = "%",
invert = false,
arrow = true,
className,
...props
}: DeltaChipProps) {
const neutral = direction === undefined && value === 0;
const rising = direction !== undefined ? direction > 0 : value > 0;
const good = invert ? !rising : rising;
return (
<span
className={cx("wisp-chip", neutral ? "is-flat" : good ? "is-up" : "is-down", className)}
{...props}
>
{arrow ? <span aria-hidden>{neutral ? "•" : rising ? "▲" : "▼"} </span> : null}
{/* What the arrow only looks like it says. */}
<span className="wisp-chip-read">{neutral ? "unchanged " : rising ? "up " : "down "}</span>
{/* The magnitude is marked so a parent driving a shared clock (`StatTile`)
can write it from a ref without re-rendering the whole chip. */}
<span data-wisp-num>
{Math.abs(value).toFixed(decimals)}
{suffix}
</span>
</span>
);
}
Certified against the contract
- ✓Reduced motion paints one composed still frame — never a blank box
- ✓Pauses off-screen and in hidden tabs
- ✓Re-reads design tokens when the theme changes
- ✓SSR-safe: no hydration mismatch, no layout shift
- ✓Decorative layers are aria-hidden and pointer-events-none
- ✓Device pixel ratio clamped
- ✓Zero runtime dependencies beyond React