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.json
Stat · DOMsemantic
Revenueup 12.4%
Signupsup 4.8%
p95 latencydown 18.2%
Error rateup 3.1%
Churndown 6.5%
arrow = direction · colour = sentiment

DeltaChipesc or double-click to exit full screendouble-tap to exit

data/delta-chip.tsx
"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