{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"earnings-chart","type":"registry:component","title":"Earnings chart","description":"A bar chart that changes instead of redrawing: bars glide between periods, the scale and the total roll, a readout follows the pointer.","author":"Hairline UI","dependencies":[],"registryDependencies":["https://hairlineui.com/r/number-roll.json","https://hairlineui.com/r/text-morph.json"],"files":[{"path":"registry/earnings-chart.tsx","type":"registry:component","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { NumberRoll } from \"./number-roll\";\nimport { TextMorph } from \"./text-morph\";\n\n/* ─────────────────────────────────────────────────────────\n * EARNINGS CHART: a bar chart that changes instead of redrawing\n *\n *   arrive    the first time it's on screen the bars grow up out\n *             of the baseline one after another and the total\n *             rolls up from zero\n *   period    6M, 1Y, All: the pill is thrown to the choice. Bars\n *             the two periods share glide to their new place and\n *             height, bars that leave fold away on their side,\n *             new ones grow in, the scale's labels roll and so\n *             does the total\n *   hover     a hairline column follows the pointer from bar to\n *             bar and the readout at its top slides along, its\n *             month morphing and its amount rolling\n *   change    the difference from the period before sits in a\n *             chip that turns emerald or red, its arrow turning\n *             with it\n *\n * The plot is one tab stop: arrow keys move between bars and\n * the readout is announced. Every figure is also in a table for\n * screen readers.\n * ───────────────────────────────────────────────────────── */\n\nexport interface EarningsBar {\n  /** Stays the same across periods for the same month, so shared bars glide instead of popping. */\n  key: string;\n  /** Under the bar, e.g. \"Aug\". */\n  label: string;\n  value: number;\n  /** In the readout, e.g. \"August 2026\". Defaults to label. */\n  title?: string;\n}\n\nexport interface EarningsPeriod {\n  id: string;\n  /** In the switch, e.g. \"6M\". */\n  label: string;\n  bars: EarningsBar[];\n  /** The total of the period before, for the change chip. */\n  previous?: number;\n  /** After the chip, e.g. \"vs the 6 months before\". */\n  comparison?: string;\n}\n\nexport interface EarningsChartProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"onChange\"> {\n  title?: string;\n  periods: EarningsPeriod[];\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (id: string) => void;\n  /** Intl.NumberFormat options for amounts. */\n  format?: Intl.NumberFormatOptions;\n  locales?: string | string[];\n  /** The bar drawn in your primary colour, by key. Defaults to the last bar. */\n  highlight?: string;\n}\n\ntype Column = EarningsBar & { left: number; width: number; height: number; index: number; leaving?: \"start\" | \"end\" };\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst THROW = \"cubic-bezier(0.34,1.36,0.64,1)\";\nconst GLIDE = \"cubic-bezier(0.77,0,0.175,1)\";\nconst MORPH = 560;\nconst PLOT = 176; // the bars' area\nconst HEADROOM = 30; // above the tallest bar, for the readout\nconst LABELS = 26;\nconst GUTTER = 44; // the scale's labels, left of the bars\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40\";\nconst CURRENCY: Intl.NumberFormatOptions = { style: \"currency\", currency: \"USD\", maximumFractionDigits: 0 };\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(\n    subscribeReduced,\n    () => window.matchMedia(reducedQuery).matches,\n    () => false,\n  );\n\n/** A round top for the scale: 4,820 → 5,000. */\nfunction niceMax(value: number) {\n  if (value <= 0) return 1;\n  const step = 10 ** Math.floor(Math.log10(value));\n  for (const m of [1, 1.2, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10]) if (m * step >= value) return m * step;\n  return 10 * step;\n}\n\n/** One bar and its label. It grows out of the baseline when it first appears, and folds away when it leaves. */\nfunction ColumnView({\n  col,\n  grown,\n  delay,\n  tone,\n  reduced,\n  label,\n}: {\n  col: Column;\n  grown: boolean;\n  delay: number;\n  tone: string;\n  reduced: boolean;\n  label: boolean;\n}) {\n  const [born, setBorn] = React.useState(false);\n  React.useEffect(() => {\n    let inner = 0;\n    const outer = requestAnimationFrame(() => (inner = requestAnimationFrame(() => setBorn(true))));\n    return () => {\n      cancelAnimationFrame(outer);\n      cancelAnimationFrame(inner);\n    };\n  }, []);\n  const up = grown && born && !col.leaving;\n  return (\n    <div\n      aria-hidden=\"true\"\n      className=\"absolute top-0 h-full\"\n      style={{\n        left: `${col.left}%`,\n        width: `${col.width}%`,\n        opacity: col.leaving ? 0 : 1,\n        transition: reduced ? \"none\" : `left ${MORPH}ms ${GLIDE}, width ${MORPH}ms ${GLIDE}, opacity ${col.leaving ? 240 : 360}ms ${EASE}`,\n      }}\n    >\n      <div\n        className={`absolute bottom-[26px] left-1/2 w-[min(24px,62%)] -translate-x-1/2 rounded-t-[4px] ${tone}`}\n        style={{\n          height: up ? Math.max(2, col.height) : 0,\n          transition: reduced ? \"none\" : `height 640ms ${EASE} ${delay}ms, background-color 300ms ${EASE}`,\n        }}\n      />\n      <span\n        className=\"absolute bottom-0 left-1/2 -translate-x-1/2 whitespace-nowrap text-[11px] leading-[18px] text-muted-foreground\"\n        style={{ opacity: label ? 1 : 0, transition: reduced ? \"none\" : `opacity 300ms ${EASE}` }}\n      >\n        {col.label}\n      </span>\n    </div>\n  );\n}\n\nexport function EarningsChart({\n  title = \"Earnings\",\n  periods,\n  value,\n  defaultValue,\n  onValueChange,\n  format = CURRENCY,\n  locales,\n  highlight,\n  className = \"\",\n  ...props\n}: EarningsChartProps) {\n  const reduced = useReducedMotion();\n  const id = React.useId();\n  const [own, setOwn] = React.useState(defaultValue ?? periods[periods.length - 1]?.id);\n  const periodId = value ?? own;\n  const period = periods.find((p) => p.id === periodId) ?? periods[0];\n  const bars = period?.bars ?? [];\n  // Set by the card's width in CSS and measured below.\n  const [plotH, setPlotH] = React.useState(PLOT);\n  const total = bars.reduce((sum, b) => sum + b.value, 0);\n  const max = niceMax(Math.max(0, ...bars.map((b) => b.value)));\n  const slot = bars.length ? 100 / bars.length : 100;\n  const layout: Column[] = bars.map((b, index) => ({ ...b, index, left: index * slot, width: slot, height: (b.value / max) * (plotH - HEADROOM) }));\n  const hi = highlight ?? bars[bars.length - 1]?.key;\n\n  const [visible, setVisible] = React.useState(false);\n  const [active, setActive] = React.useState<number | null>(null);\n  const [keyboard, setKeyboard] = React.useState(false);\n  const [leaving, setLeaving] = React.useState<Column[]>([]);\n  const [prev, setPrev] = React.useState(() => ({ id: periodId, bars, layout }));\n  const [pill, setPill] = React.useState({ left: 0, width: 0, ready: false });\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const plotRef = React.useRef<HTMLDivElement>(null);\n  const barsRef = React.useRef<HTMLDivElement>(null);\n  const chipRefs = React.useRef<(HTMLButtonElement | null)[]>([]);\n  const [width, setWidth] = React.useState(0);\n\n  // A new period: bars it doesn't have fold away on the side they were on, past the bars both periods share.\n  if (prev.id !== periodId) {\n    const keys = new Set(bars.map((b) => b.key));\n    const shared = prev.layout.filter((c) => keys.has(c.key)).map((c) => c.index);\n    const firstShared = shared.length ? Math.min(...shared) : prev.layout.length;\n    setLeaving(\n      prev.layout\n        .filter((c) => !keys.has(c.key))\n        .map((c) => {\n          const side = c.index < firstShared ? \"start\" : \"end\";\n          // Same key as before, so the bar that was there is the one that folds away.\n          return { ...c, leaving: side, left: side === \"start\" ? 0 : 100, width: 0, height: 0 };\n        }),\n    );\n    setPrev({ id: periodId, bars, layout });\n    setActive(null);\n  } else if (prev.bars !== bars) {\n    setPrev({ id: periodId, bars, layout });\n  }\n\n  React.useEffect(() => {\n    if (!leaving.length) return;\n    const timer = window.setTimeout(() => setLeaving([]), MORPH);\n    return () => window.clearTimeout(timer);\n  }, [leaving]);\n\n  // The plot's height is set by the card's width in CSS; read it so the drawing fits.\n  React.useLayoutEffect(() => {\n    const el = plotRef.current;\n    if (!el) return;\n    const measure = () =>\n      setPlotH((h) => {\n        const next = el.offsetHeight - LABELS;\n        return next > 0 && next !== h ? next : h;\n      });\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, []);\n\n  React.useEffect(() => {\n    const el = rootRef.current;\n    if (!el) return;\n    const observer = new IntersectionObserver(\n      ([entry]) => {\n        if (entry.isIntersecting) {\n          setVisible(true);\n          observer.disconnect();\n        }\n      },\n      { rootMargin: \"0px 0px -10% 0px\" },\n    );\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, []);\n\n  // Narrow bars show every other label (or fewer), always keeping the latest.\n  React.useLayoutEffect(() => {\n    const el = barsRef.current;\n    if (!el) return;\n    const measure = () => setWidth((w) => (w === el.offsetWidth ? w : el.offsetWidth));\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, []);\n  const every = width && bars.length ? Math.max(1, Math.ceil(30 / (width / bars.length))) : 1;\n\n  React.useLayoutEffect(() => {\n    const el = chipRefs.current[periods.findIndex((p) => p.id === periodId)];\n    if (!el) return;\n    setPill((p) => (p.left === el.offsetLeft && p.width === el.offsetWidth ? p : { left: el.offsetLeft, width: el.offsetWidth, ready: p.width > 0 }));\n  }, [periodId, periods]);\n\n  const choose = (next: string) => {\n    if (next === periodId) return;\n    if (value === undefined) setOwn(next);\n    onValueChange?.(next);\n  };\n\n  const pointAt = (clientX: number) => {\n    const r = barsRef.current?.getBoundingClientRect();\n    if (!r || !bars.length) return null;\n    return Math.min(bars.length - 1, Math.max(0, Math.floor(((clientX - r.left) / r.width) * bars.length)));\n  };\n\n  const onKeyDown = (e: React.KeyboardEvent) => {\n    if (!bars.length) return;\n    const from =\n      active ??\n      Math.max(\n        0,\n        bars.findIndex((b) => b.key === hi),\n      );\n    const moves: Record<string, number> = { ArrowLeft: from - 1, ArrowRight: from + 1, Home: 0, End: bars.length - 1 };\n    if (e.key === \"Escape\") return setActive(null);\n    if (!(e.key in moves)) return;\n    e.preventDefault();\n    setKeyboard(true);\n    setActive(Math.min(bars.length - 1, Math.max(0, active === null ? from : moves[e.key])));\n  };\n\n  const fmt = (n: number) => new Intl.NumberFormat(locales, format).format(n);\n  const change = period?.previous ? (total - period.previous) / period.previous : null;\n  // The chip folds away for a period with nothing to compare against, holding its last figure while it goes.\n  const [lastChange, setLastChange] = React.useState(change ?? 0);\n  if (change !== null && change !== lastChange) setLastChange(change);\n  const shownChange = change ?? lastChange;\n  const up = shownChange >= 0;\n  const comparable = periods.some((p) => p.previous);\n  const shown = active !== null ? bars[active] : null;\n  const col = active !== null ? layout[active] : null;\n  const compact = { ...format, notation: \"compact\" as const, minimumFractionDigits: 0, maximumFractionDigits: 1 };\n\n  return (\n    <div ref={rootRef} className={`@container w-full rounded-[22px] bg-background shadow-[0_0_0_1px_var(--border)] ${className}`} {...props}>\n      <div className=\"p-4 @md:p-5 [--gutter:30px] @md:[--gutter:40px] [--plot:148px] @md:[--plot:176px]\">\n        {/* The title and its control share a row; the figures sit under both. */}\n        <div className=\"flex flex-wrap items-center justify-between gap-x-3 gap-y-2\">\n          <p className=\"text-[12px] text-muted-foreground @md:text-[13px]\">{title}</p>\n          <div role=\"radiogroup\" aria-label=\"Period\" className=\"relative -my-1 inline-flex shrink-0 rounded-full bg-muted p-0.5\">\n            <span\n              aria-hidden=\"true\"\n              className=\"absolute inset-y-0.5 left-0 rounded-full bg-background shadow-[0_0_0_1px_var(--border)]\"\n              style={{\n                width: pill.width,\n                transform: `translateX(${pill.left}px)`,\n                transition: pill.ready && !reduced ? `transform 460ms ${THROW}, width 380ms ${EASE}` : \"none\",\n              }}\n            />\n            {periods.map((p, i) => {\n              const selected = p.id === periodId;\n              return (\n                <button\n                  key={p.id}\n                  ref={(el) => {\n                    chipRefs.current[i] = el;\n                  }}\n                  type=\"button\"\n                  role=\"radio\"\n                  aria-checked={selected}\n                  tabIndex={selected ? 0 : -1}\n                  onClick={() => choose(p.id)}\n                  onKeyDown={(e) => {\n                    if (e.key !== \"ArrowRight\" && e.key !== \"ArrowLeft\") return;\n                    e.preventDefault();\n                    const next = (i + (e.key === \"ArrowRight\" ? 1 : -1) + periods.length) % periods.length;\n                    choose(periods[next].id);\n                    chipRefs.current[next]?.focus();\n                  }}\n                  className={`relative h-6 rounded-full px-2.5 text-[11.5px] font-medium @md:h-7 @md:px-3 @md:text-[12.5px] tabular-nums transition-colors duration-300 ${FOCUS} ${\n                    selected ? \"text-foreground\" : \"text-muted-foreground hover:text-foreground\"\n                  }`}\n                >\n                  {p.label}\n                </button>\n              );\n            })}\n          </div>\n        </div>\n        <p className=\"mt-0.5 text-[22px] font-medium leading-tight tracking-[-0.03em] @md:text-[26px] text-foreground\">\n          <NumberRoll value={visible ? total : 0} format={format} locales={locales} duration={1000} />\n        </p>\n        {(comparable || period?.comparison) && (\n          <p className=\"mt-1 flex items-center text-[11.5px] text-muted-foreground @md:text-[12.5px]\">\n            <span\n              className=\"grid\"\n              style={{\n                gridTemplateColumns: change !== null ? \"1fr\" : \"0fr\",\n                opacity: change !== null ? 1 : 0,\n                transition: reduced ? \"none\" : `grid-template-columns 420ms ${EASE}, opacity 240ms ${EASE}`,\n              }}\n            >\n              <span className=\"min-w-0 pr-2 [clip-path:inset(-4px_-2px)]\">\n                <span\n                  className={`inline-flex items-center gap-1 whitespace-nowrap rounded-full px-1.5 py-px font-medium tabular-nums transition-colors duration-300 ${\n                    up ? \"bg-emerald-500/10 text-emerald-600\" : \"bg-red-500/10 text-red-600\"\n                  }`}\n                >\n                  <svg\n                    viewBox=\"0 0 12 12\"\n                    aria-hidden=\"true\"\n                    className=\"size-3\"\n                    style={{ transform: up ? \"none\" : \"rotate(180deg)\", transition: reduced ? \"none\" : `transform 420ms ${THROW}` }}\n                  >\n                    <path\n                      d=\"M6 9.5V2.5M2.8 5.6 6 2.5l3.2 3.1\"\n                      fill=\"none\"\n                      stroke=\"currentColor\"\n                      strokeWidth=\"1.5\"\n                      strokeLinecap=\"round\"\n                      strokeLinejoin=\"round\"\n                    />\n                  </svg>\n                  <NumberRoll value={visible ? Math.abs(shownChange) : 0} format={{ style: \"percent\", maximumFractionDigits: 1 }} duration={800} />\n                </span>\n              </span>\n            </span>\n            <span className=\"whitespace-nowrap\">\n              <TextMorph>{period?.comparison ?? \" \"}</TextMorph>\n            </span>\n          </p>\n        )}\n\n        <div\n          ref={plotRef}\n          role=\"group\"\n          tabIndex={0}\n          aria-label={`${title}, ${period?.label ?? \"\"}. Use the arrow keys to read each bar.`}\n          onKeyDown={onKeyDown}\n          onBlur={() => keyboard && (setActive(null), setKeyboard(false))}\n          onPointerMove={(e) => {\n            setKeyboard(false);\n            setActive(pointAt(e.clientX));\n          }}\n          onPointerDown={(e) => setActive(pointAt(e.clientX))}\n          onPointerLeave={(e) => e.pointerType === \"mouse\" && setActive(null)}\n          className={`relative mt-4 touch-pan-y @md:mt-5 select-none rounded-lg ${FOCUS}`}\n          style={{ height: `calc(var(--plot) + ${LABELS}px)` }}\n        >\n          {/* The scale: rolling labels in the gutter, the top and the middle as hairlines, and the baseline. */}\n          {[1, 0.5, 0].map((f) => (\n            <div key={f} aria-hidden=\"true\" className=\"absolute right-0\" style={{ left: 0, top: HEADROOM + (1 - f) * (plotH - HEADROOM) }}>\n              <span\n                className=\"absolute left-0 top-0 -translate-y-1/2 text-[10.5px] tabular-nums leading-none text-muted-foreground/80\"\n                style={{ width: \"calc(var(--gutter) - 8px)\", textAlign: \"right\" }}\n              >\n                <NumberRoll value={visible ? max * f : 0} format={compact} locales={locales} duration={700} />\n              </span>\n              <div className=\"border-t border-border\" style={{ marginLeft: \"var(--gutter)\" }} />\n            </div>\n          ))}\n\n          <div ref={barsRef} className=\"absolute inset-y-0 right-0\" style={{ left: \"var(--gutter)\" }}>\n            {/* The hovered column: a hairline lane from the readout down to the baseline. */}\n            <div\n              aria-hidden=\"true\"\n              className=\"pointer-events-none absolute rounded-[10px] bg-foreground/[0.02] shadow-[inset_0_0_0_1px_var(--border)]\"\n              style={{\n                top: 2,\n                height: plotH - 2,\n                left: col ? `calc(${col.left}% + 2px)` : 0,\n                width: col ? `calc(${col.width}% - 4px)` : 0,\n                opacity: col ? 1 : 0,\n                transition: reduced ? \"none\" : `left 380ms ${THROW}, width 380ms ${EASE}, opacity 200ms ${EASE}`,\n              }}\n            />\n\n            {[...layout, ...leaving.filter((l) => !bars.some((b) => b.key === l.key))].map((c) => (\n              <ColumnView\n                key={c.key}\n                col={c}\n                grown={visible}\n                delay={c.leaving ? 0 : Math.min(c.index, 12) * 28}\n                label={!c.leaving && (bars.length - 1 - c.index) % every === 0}\n                reduced={reduced}\n                tone={\n                  c.key === hi\n                    ? \"bg-primary\"\n                    : active !== null && bars[active]?.key === c.key\n                      ? \"bg-foreground/25\"\n                      : active !== null\n                        ? \"bg-foreground/[0.07]\"\n                        : \"bg-foreground/[0.11]\"\n                }\n              />\n            ))}\n\n            {/* The readout rides the top of the lane: month morphs, amount rolls. */}\n            <div\n              aria-hidden=\"true\"\n              className=\"pointer-events-none absolute top-[5px] flex h-[22px] -translate-x-1/2 items-center gap-1.5 whitespace-nowrap rounded-full bg-background px-2.5 text-[11.5px] shadow-[0_0_0_1px_var(--border)]\"\n              style={{\n                left: col ? `clamp(56px, ${col.left + col.width / 2}%, calc(100% - 56px))` : \"50%\",\n                opacity: shown ? 1 : 0,\n                scale: shown ? \"1\" : \"0.94\",\n                transition: reduced ? \"none\" : `left 380ms ${THROW}, opacity 200ms ${EASE}, scale 260ms ${EASE}`,\n              }}\n            >\n              <span className=\"text-muted-foreground\">\n                <TextMorph>{shown ? (shown.title ?? shown.label) : \" \"}</TextMorph>\n              </span>\n              <span className=\"font-medium tabular-nums text-foreground\">\n                <NumberRoll value={shown?.value ?? 0} format={format} locales={locales} duration={600} />\n              </span>\n            </div>\n          </div>\n        </div>\n\n        <p aria-live=\"polite\" className=\"sr-only\">\n          {keyboard && shown ? `${shown.title ?? shown.label}: ${fmt(shown.value)}` : \"\"}\n        </p>\n        <table className=\"sr-only\">\n          <caption>\n            {title}, {period?.label}: {fmt(total)}\n          </caption>\n          <tbody>\n            {bars.map((b) => (\n              <tr key={b.key} id={`${id}-${b.key}`}>\n                <th scope=\"row\">{b.title ?? b.label}</th>\n                <td>{fmt(b.value)}</td>\n              </tr>\n            ))}\n          </tbody>\n        </table>\n      </div>\n    </div>\n  );\n}\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/earnings-chart"}