Skip to content

Signed change with one arrow that turns, tinted by whether the change is good.

Numbers & charts@number-flow/reactmotion

01Preview

Compared with last
  • Recurring revenue$84,210Up 12.4%
  • Churn1.9%Down 0.8%
  • p95 latency182 msDown 6.7%
  • Enterprise seats312Up 4.6%

Enterprise seats since last month Up 4.6%

02Install

Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:

npm install @number-flow/react motion

03Usage

import { DeltaBadge } from "@/components/ui/delta-badge";

<DeltaBadge value={12.4} />

// Churn falling is good news.
<DeltaBadge value={-0.8} inverse />

04Source

"use client";
import NumberFlow, { type Format } from "@number-flow/react";
import { motion, useReducedMotion } from "motion/react";
import { useSyncExternalStore } from "react";
import { cn } from "@/lib/cn";
import { ease, spring } from "@/lib/motion";

const noop = () => () => {};
function useLocale(locale?: string) {
  const detected = useSyncExternalStore(
    noop,
    () => Intl.NumberFormat().resolvedOptions().locale,
    () => "en-US",
  );
  return locale ?? detected;
}

export type DeltaTrend = "up" | "down" | "flat" | "none";
export type DeltaTone = "good" | "bad" | "neutral";

/**
 * Where a change points and whether that's good, judged on the number as it
 * will be shown: +0.04% rounds to 0% and reads as flat, not as a green 0%.
 */
export function getDelta(value: number | null | undefined, { decimals = 1, inverse = false }: { decimals?: number; inverse?: boolean } = {}) {
  if (value == null || !Number.isFinite(value)) return { trend: "none" as DeltaTrend, tone: "neutral" as DeltaTone };
  const f = 10 ** decimals;
  const rounded = Math.round(Math.abs(value) * f) / f;
  if (rounded === 0) return { trend: "flat" as DeltaTrend, tone: "neutral" as DeltaTone };
  const up = value > 0;
  return { trend: (up ? "up" : "down") as DeltaTrend, tone: (up !== inverse ? "good" : "bad") as DeltaTone };
}

const expo = `cubic-bezier(${ease.out.join(",")})`;
const timing = {
  transformTiming: { duration: 550, easing: expo },
  spinTiming: { duration: 550, easing: expo },
  opacityTiming: { duration: 200, easing: "ease-out" },
};

// One arrow, turned: ↗ up, → flat, ↘ down. A flip reads as the same arrow changing its mind.
const rotation: Record<DeltaTrend, number> = { up: -45, flat: 0, down: 45, none: 0 };

export type DeltaBadgeProps = Omit<React.ComponentProps<"span">, "children"> & {
  /** The change. Percentage points by default (12.4 shows +12.4%). null, NaN or Infinity shows a dash. */
  value: number | null | undefined;
  /** percent shows 12.4 as +12.4%; number shows the raw change, formatted with `format`. */
  unit?: "percent" | "number";
  /** Fraction digits shown, and the precision below which a change counts as flat. */
  decimals?: number;
  /** Extra Intl options for unit="number", e.g. currency. */
  format?: Format;
  /** For latency, churn or cost: a fall is good news and turns green. */
  inverse?: boolean;
  /** Tinted pill, or colored text only for tables and running copy. */
  variant?: "soft" | "plain";
  size?: "sm" | "md";
  locale?: string;
  /** Shown when there's nothing to compare against (no prior period, or it was zero). */
  emptyLabel?: string;
};

export function DeltaBadge({
  value,
  unit = "percent",
  decimals = 1,
  format,
  inverse = false,
  variant = "soft",
  size = "md",
  locale: localeProp,
  emptyLabel = "",
  className,
  ...rest
}: DeltaBadgeProps) {
  const locale = useLocale(localeProp);
  const reduce = useReducedMotion();
  const { trend, tone } = getDelta(value, { decimals, inverse });
  const magnitude = trend === "none" ? 0 : Math.abs(value as number);

  // The sign is drawn by us (a true minus, not a hyphen) so Intl only formats the magnitude.
  const options: Format = {
    ...(unit === "percent" ? { style: "percent" } : null),
    minimumFractionDigits: unit === "percent" ? decimals : 0,
    maximumFractionDigits: decimals,
    ...format,
    signDisplay: "never",
  };
  const shown = unit === "percent" ? magnitude / 100 : magnitude;
  const sign = trend === "up" ? "+" : trend === "down" ? "" : "";
  const text = new Intl.NumberFormat(locale, options).format(shown);
  const spoken = trend === "none" ? "No comparison" : trend === "flat" ? `No change, ${text}` : `${trend === "up" ? "Up" : "Down"} ${text}`;

  return (
    <span
      data-slot="delta-badge"
      data-trend={trend}
      data-tone={tone}
      data-variant={variant}
      data-size={size}
      className={cn(
        "inline-flex shrink-0 items-center whitespace-nowrap font-medium tabular transition-[background-color,color] duration-300 ease-out",
        size === "sm" ? "gap-0.5 text-[11px]" : "gap-1 text-[12px]",
        variant === "soft" && (size === "sm" ? "h-[18px] rounded-full pl-1 pr-1.5" : "h-[22px] rounded-full pl-1.5 pr-2"),
        tone === "good" && "text-success",
        tone === "bad" && "text-danger",
        tone === "neutral" && "text-fg-3",
        variant === "soft" && tone === "good" && "bg-success-soft",
        variant === "soft" && tone === "bad" && "bg-danger-soft",
        variant === "soft" && tone === "neutral" && "bg-fg/[0.06]",
        className,
      )}
      {...rest}
    >
      {trend !== "none" && (
        <span aria-hidden className="grid size-3 shrink-0 place-items-center">
          <motion.svg
            width="12"
            height="12"
            viewBox="0 0 12 12"
            fill="none"
            stroke="currentColor"
            strokeWidth={1.4}
            strokeLinecap="round"
            strokeLinejoin="round"
            initial={false}
            animate={{ rotate: rotation[trend] }}
            transition={reduce ? { duration: 0 } : spring.snappy}
          >
            <path d="M2.5 6h7M6.5 3l3 3-3 3" />
          </motion.svg>
        </span>
      )}
      <span aria-hidden className="inline-flex h-[1lh] items-center">
        {trend === "none" ? (
          <span className="px-1">{emptyLabel}</span>
        ) : (
          <NumberFlow value={shown} locales={locale} format={options} prefix={sign} animated={!reduce} {...timing} />
        )}
      </span>
      <span className="sr-only">{spoken}</span>
    </span>
  );
}

05Props

DeltaBadge

PropTypeDefaultDescription
value*number | nullThe change, in percentage points by default. null, NaN or Infinity shows emptyLabel.
unit"percent" | "number""percent"percent shows 12.4 as +12.4%; number shows the raw change (+1,240, or money via format).
decimalsnumber1Fraction digits, and the precision below which the change counts as flat.
formatIntl.NumberFormatOptionsExtra Intl options, e.g. { style: "currency", currency: "USD" } with unit="number".
inversebooleanfalseFor latency, churn or cost: a fall is good and turns green, a rise turns red.
variant"soft" | "plain""soft"Tinted pill, or colored text only for tables and sentences.
size"sm" | "md""md"18px or 22px tall.
emptyLabelstring"–"Shown when there is nothing to compare against.
localestringBCP 47 tag. Defaults to the reader's locale.

getDelta

PropTypeDefaultDescription
value, { decimals, inverse }(number | null, options) => { trend, tone }The same judgment without the badge: up, down, flat or none, and good, bad or neutral.

06Notes

Behavior

  • Flat is decided on the rounded number: +0.04% shows 0.0% in neutral gray, never a green zero.
  • The sign is a true minus (−), drawn outside Intl, while the percent sign and decimal mark follow the locale (12,4 % in French).
  • No prior period, or a prior value of zero, has no meaningful percentage; pass null or Infinity and it shows a dash instead of +∞%.

Motion

  • One arrow rotates between ↗ −45°, → 0° and ↘ 45° on the snappy spring (520/38), so a flip reads as the same arrow changing its mind.
  • Digits roll 550ms on the expo ease-out; the tint crossfades over 300ms.
  • Reduced motion sets the arrow and the digits instantly; the color still changes.

Accessibility

  • The visible sign, arrow and digits are hidden from screen readers, which hear one phrase instead: Up 12.4%, Down 0.8%, No change, or No comparison.
  • Direction is carried by the sign and the arrow as well as the color, so it survives color blindness and forced colors.
  • It isn't focusable or live; put the context (vs last month) in the text around it.