{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"combo-chart","type":"registry:component","title":"Combo chart","description":"Every day as a bar and the trend as a line on one scale: point at a day and the header becomes its readout while the line’s dot pings where it lands.","author":"Hairline UI","dependencies":[],"registryDependencies":["https://hairlineui.com/r/number-roll.json","https://hairlineui.com/r/text-morph.json"],"files":[{"path":"registry/combo-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 * COMBO CHART: every day as a bar, the trend as a line, one scale\n *\n *   arrive    the first time it's on screen the bars grow up out\n *             of the baseline in a wave and the average draws\n *             itself across them; the figures roll up from zero\n *   hover     the bar under the pointer fills while the rest\n *             dim, the line's dot slides along it and pings once\n *             where it lands, and the header becomes the readout:\n *             the title morphs to the day, the total rolls to its\n *             count, the line below to its average\n *   tiles     the best day, the daily average and the quietest\n *             day, each rolling up on arrival\n *\n * Bars and line share one scale (never two axes), so the line\n * reads in the same units as the bars. The plot is one tab stop:\n * arrow keys move between days and the readout is announced.\n * Every figure is also in a table for screen readers.\n * ───────────────────────────────────────────────────────── */\n\nexport interface ComboDay {\n  key: string;\n  /** Under the bar, e.g. \"2 Oct\". */\n  label: string;\n  value: number;\n  /** In the readout, e.g. \"Thursday 2 October\". Defaults to label. */\n  title?: string;\n}\n\nexport interface ComboChartProps extends React.HTMLAttributes<HTMLDivElement> {\n  title?: string;\n  days: ComboDay[];\n  /** The line: a rolling average over this many days. */\n  rolling?: number;\n  /** Your own line values instead (e.g. an average that includes days before the first bar). */\n  average?: number[];\n  /** The total of the period before, for the change chip. */\n  previous?: number;\n  /** The two marks' names, for the legend and the readout. */\n  series?: [string, string];\n  format?: Intl.NumberFormatOptions;\n  locales?: string | string[];\n}\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst THROW = \"cubic-bezier(0.34,1.36,0.64,1)\";\nconst PLOT = 190;\nconst TOP = 10;\nconst GUTTER = 40;\nconst LABELS = 24;\nconst STEPS = 4;\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40\";\nconst BAR = \"var(--chart-1, var(--primary))\";\nconst LINE = \"var(--chart-2, #e8743b)\";\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\nfunction niceTop(value: number) {\n  if (value <= 0) return STEPS;\n  const raw = value / STEPS;\n  const mag = 10 ** Math.floor(Math.log10(raw));\n  const step = [1, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10].map((m) => m * mag).find((s) => s >= raw) ?? 10 * mag;\n  return step * STEPS;\n}\n\nfunction monotone(points: [number, number][]) {\n  const n = points.length;\n  if (!n) return \"\";\n  const r = (v: number) => Math.round(v * 100) / 100;\n  if (n === 1) return `M${r(points[0][0])},${r(points[0][1])}`;\n  const m: number[] = [];\n  for (let i = 0; i < n - 1; i++) m.push((points[i + 1][1] - points[i][1]) / (points[i + 1][0] - points[i][0] || 1));\n  const t: number[] = [m[0]];\n  for (let i = 1; i < n - 1; i++) t.push(m[i - 1] * m[i] <= 0 ? 0 : (m[i - 1] + m[i]) / 2);\n  t.push(m[n - 2]);\n  for (let i = 0; i < n - 1; i++) {\n    if (m[i] === 0) {\n      t[i] = 0;\n      t[i + 1] = 0;\n      continue;\n    }\n    const a = t[i] / m[i];\n    const b = t[i + 1] / m[i];\n    const s = a * a + b * b;\n    if (s > 9) {\n      const k = 3 / Math.sqrt(s);\n      t[i] = k * a * m[i];\n      t[i + 1] = k * b * m[i];\n    }\n  }\n  let d = `M${r(points[0][0])},${r(points[0][1])}`;\n  for (let i = 0; i < n - 1; i++) {\n    const [x0, y0] = points[i];\n    const [x1, y1] = points[i + 1];\n    const dx = (x1 - x0) / 3;\n    d += `C${r(x0 + dx)},${r(y0 + t[i] * dx)} ${r(x1 - dx)},${r(y1 - t[i + 1] * dx)} ${r(x1)},${r(y1)}`;\n  }\n  return d;\n}\n\nexport function ComboChart({\n  title = \"Orders\",\n  days,\n  rolling = 7,\n  average: averageProp,\n  previous,\n  series = [\"Orders\", \"7-day average\"],\n  format,\n  locales,\n  className = \"\",\n  ...props\n}: ComboChartProps) {\n  const reduced = useReducedMotion();\n  const id = React.useId();\n  const [visible, setVisible] = React.useState(false);\n  const [plotH, setPlotH] = React.useState(PLOT);\n  const [active, setActive] = React.useState<number | null>(null);\n  const [keyboard, setKeyboard] = React.useState(false);\n  const [width, setWidth] = React.useState(0);\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const chartRef = React.useRef<HTMLDivElement>(null);\n  const plotRef = React.useRef<HTMLDivElement>(null);\n  const pingRef = React.useRef<HTMLSpanElement>(null);\n\n  const n = days.length;\n  const values = days.map((d) => d.value);\n  // A rolling average over the days available (the first few average over fewer days).\n  const average = React.useMemo(\n    () =>\n      averageProp ??\n      values.map((_, i) => {\n        const from = Math.max(0, i - rolling + 1);\n        const run = values.slice(from, i + 1);\n        return run.reduce((s, v) => s + v, 0) / run.length;\n      }),\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n    [averageProp, values.join(\",\"), rolling],\n  );\n  const max = niceTop(Math.max(0, ...values, ...average));\n  const total = values.reduce((s, v) => s + v, 0);\n  const daily = n ? total / n : 0;\n  const best = values.indexOf(Math.max(...values));\n  const quiet = values.indexOf(Math.min(...values));\n  const change = previous ? (total - previous) / previous : null;\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 = chartRef.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  React.useLayoutEffect(() => {\n    const el = plotRef.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\n  // The dot pings once each time it lands on a new day.\n  React.useEffect(() => {\n    if (active === null || reduced) return;\n    const ring = pingRef.current;\n    if (!ring) return;\n    const timer = window.setTimeout(() => {\n      ring.animate(\n        [\n          { transform: \"translate(-50%, -50%) scale(1)\", opacity: 0.45 },\n          { transform: \"translate(-50%, -50%) scale(2.6)\", opacity: 0 },\n        ],\n        { duration: 640, easing: \"cubic-bezier(0.22,1,0.36,1)\" },\n      );\n    }, 180);\n    return () => window.clearTimeout(timer);\n  }, [active, reduced]);\n\n  const slot = n ? width / n : 0;\n  const cx = (i: number) => (i + 0.5) * slot;\n  const y = (v: number) => TOP + (plotH - TOP) * (1 - v / max);\n  const line = width ? monotone(average.map((v, i) => [cx(i), y(v)] as [number, number])) : \"\";\n  const barWidth = Math.max(3, Math.min(24, slot * 0.62));\n\n  const pointAt = (clientX: number) => {\n    const r = plotRef.current?.getBoundingClientRect();\n    if (!r || !n) return null;\n    return Math.min(n - 1, Math.max(0, Math.floor(((clientX - r.left) / r.width) * n)));\n  };\n  const onKeyDown = (e: React.KeyboardEvent) => {\n    if (!n) return;\n    const from = active ?? n - 1;\n    const moves: Record<string, number> = { ArrowLeft: from - 1, ArrowRight: from + 1, Home: 0, End: n - 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(n - 1, Math.max(0, active === null ? from : moves[e.key])));\n  };\n\n  const fmt = (v: number) => new Intl.NumberFormat(locales, { maximumFractionDigits: 0, ...format }).format(v);\n  const name = (i: number) => days[i]?.title ?? days[i]?.label ?? \"\";\n  const every = width && n ? Math.max(1, Math.ceil(44 / (width / n))) : 1;\n  // Labels thin out on narrow screens; the hovered one always shows, and its close neighbours step aside for it.\n  const label = (i: number) => active === i || ((n - 1 - i) % every === 0 && (active === null || every === 1 || Math.abs(i - active) >= every));\n  const whole = { maximumFractionDigits: 0, ...format };\n  const compact: Intl.NumberFormatOptions = { ...format, notation: \"compact\", 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:150px] @md:[--plot:190px]\">\n        <div className=\"flex flex-wrap items-start justify-between gap-x-4 gap-y-3\">\n          <div className=\"min-w-0\">\n            <p className=\"text-[12px] text-muted-foreground @md:text-[13px]\">\n              <TextMorph>{active !== null ? `${title} on ${name(active)}` : title}</TextMorph>\n            </p>\n            <p className=\"mt-1 flex flex-wrap items-center gap-x-2.5 gap-y-1\">\n              <span className=\"text-[22px] font-medium leading-tight tracking-[-0.03em] @md:text-[26px] text-foreground\">\n                <NumberRoll\n                  value={visible ? (active !== null ? values[active] : total) : 0}\n                  format={whole}\n                  locales={locales}\n                  duration={active !== null ? 600 : 1000}\n                />\n              </span>\n              {change !== null && (\n                <span\n                  className=\"grid\"\n                  style={{\n                    gridTemplateColumns: active === null ? \"1fr\" : \"0fr\",\n                    opacity: active === null ? 1 : 0,\n                    transition: reduced ? \"none\" : `grid-template-columns 380ms ${EASE}, opacity 220ms ${EASE}`,\n                  }}\n                >\n                  <span className=\"min-w-0 [clip-path:inset(-4px_-2px)]\">\n                    <span\n                      className={`inline-flex items-center gap-1 whitespace-nowrap rounded-full px-1.5 py-px text-[12.5px] font-medium ${\n                        change >= 0 ? \"bg-emerald-500/10 text-emerald-600\" : \"bg-red-500/10 text-red-600\"\n                      }`}\n                    >\n                      <svg viewBox=\"0 0 12 12\" aria-hidden=\"true\" className=\"size-3\" style={{ transform: change >= 0 ? \"none\" : \"rotate(180deg)\" }}>\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(change) : 0} format={{ style: \"percent\", maximumFractionDigits: 1 }} duration={700} />\n                    </span>\n                  </span>\n                </span>\n              )}\n            </p>\n            <p className=\"mt-1 flex items-baseline gap-[0.3em] whitespace-nowrap text-[11.5px] text-muted-foreground @md:text-[12.5px]\">\n              <NumberRoll value={visible ? (active !== null ? average[active] : daily) : 0} format={whole} locales={locales} duration={700} />\n              <TextMorph>{active !== null ? `on the ${series[1].toLowerCase()}` : \"a day on average\"}</TextMorph>\n            </p>\n          </div>\n        </div>\n\n        {/* The legend: a bar key and a line key, so the two read by shape as well as colour. */}\n        <div className=\"mt-3 flex items-center gap-4 text-[11.5px] text-muted-foreground @md:mt-4 @md:text-[12px]\">\n          <span className=\"flex items-center gap-1.5\">\n            <span aria-hidden=\"true\" className=\"h-2.5 w-2 rounded-t-[2px]\" style={{ background: BAR }} />\n            {series[0]}\n          </span>\n          <span className=\"flex items-center gap-1.5\">\n            <span aria-hidden=\"true\" className=\"h-0.5 w-3.5 rounded-full\" style={{ background: LINE }} />\n            {series[1]}\n          </span>\n        </div>\n\n        <div ref={chartRef} className=\"relative mt-3\" style={{ height: `calc(var(--plot) + ${LABELS}px)` }}>\n          {Array.from({ length: STEPS + 1 }, (_, k) => 1 - k / STEPS).map((f) => (\n            <div key={f} aria-hidden=\"true\" className=\"absolute inset-x-0\" style={{ top: TOP + (plotH - TOP) * (1 - f) }}>\n              <span\n                className=\"absolute left-0 top-0 -translate-y-1/2 text-[10px] leading-none tabular-nums 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\n            ref={plotRef}\n            role=\"group\"\n            tabIndex={0}\n            aria-label={`${title}: ${series[0]} with the ${series[1].toLowerCase()}. Use the arrow keys to read each day.`}\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={`absolute inset-y-0 right-0 touch-pan-y select-none rounded-lg ${FOCUS}`}\n            style={{ left: \"var(--gutter)\" }}\n          >\n            {days.map((d, i) => {\n              const hovered = active === i;\n              return (\n                <React.Fragment key={d.key}>\n                  <span\n                    aria-hidden=\"true\"\n                    className=\"absolute rounded-t-[4px]\"\n                    style={{\n                      left: cx(i) - barWidth / 2,\n                      width: barWidth,\n                      top: TOP,\n                      height: plotH - TOP,\n                      transformOrigin: \"bottom\",\n                      // The bar is drawn full height and scaled, so its 4px rounded top stays round while it grows.\n                      clipPath: `inset(${visible ? ((plotH - TOP) * (1 - d.value / max)).toFixed(1) : plotH - TOP}px 0 0 0 round 4px 4px 0 0)`,\n                      background: BAR,\n                      opacity: hovered ? 1 : active !== null ? 0.18 : 0.32,\n                      transition: reduced ? \"none\" : `clip-path 640ms ${EASE} ${Math.min(i, 40) * 14}ms, opacity 240ms ${EASE}`,\n                    }}\n                  />\n                  <span\n                    aria-hidden=\"true\"\n                    className={`absolute -translate-x-1/2 whitespace-nowrap text-[11px] leading-none transition-colors duration-200 ${hovered ? \"text-foreground\" : \"text-muted-foreground\"}`}\n                    style={{ left: cx(i), top: plotH + 9, opacity: label(i) ? 1 : 0 }}\n                  >\n                    {d.label}\n                  </span>\n                </React.Fragment>\n              );\n            })}\n\n            <div\n              className=\"pointer-events-none absolute inset-x-0 top-0\"\n              style={{\n                height: plotH,\n                clipPath: visible ? \"inset(-8px -8px -8px -8px)\" : \"inset(-8px 100% -8px -8px)\",\n                transition: reduced ? \"none\" : `clip-path 1100ms ${EASE} 260ms`,\n              }}\n            >\n              <svg aria-hidden=\"true\" width={width} height={plotH} className=\"absolute inset-0 overflow-visible\">\n                {line && <path d={line} fill=\"none\" stroke={LINE} strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />}\n              </svg>\n            </div>\n\n            {/* The line's dot, and the ring that pings when it lands. */}\n            {[pingRef, null].map((ref, k) => (\n              <span\n                key={k}\n                ref={ref ?? undefined}\n                aria-hidden=\"true\"\n                className={`pointer-events-none absolute size-2.5 rounded-full ${ref ? \"\" : \"shadow-[0_0_0_2px_var(--background)]\"}`}\n                style={{\n                  left: cx(active ?? n - 1),\n                  top: y(average[active ?? n - 1] ?? 0),\n                  transform: \"translate(-50%, -50%)\",\n                  background: LINE,\n                  opacity: active !== null ? (ref ? 0 : 1) : 0,\n                  transition: reduced ? \"none\" : `left 260ms ${THROW}, top 260ms ${THROW}, opacity 200ms ${EASE}`,\n                }}\n              />\n            ))}\n          </div>\n        </div>\n\n        {/* The day's highlights as a compact strip under a hairline. */}\n        <dl className=\"mt-4 grid grid-cols-3 divide-x divide-border border-t border-border pt-2.5 @md:mt-5 @md:pt-3\">\n          {[\n            { label: \"Best day\", value: values[best] ?? 0, note: days[best]?.label },\n            { label: \"Daily average\", value: daily, note: `over ${n} days` },\n            { label: \"Quietest day\", value: values[quiet] ?? 0, note: days[quiet]?.label },\n          ].map((t) => (\n            <div key={t.label} className=\"min-w-0 px-2.5 first:pl-0 last:pr-0 @md:px-3.5\">\n              <dt className=\"truncate text-[11px] text-muted-foreground @md:text-[11.5px]\">{t.label}</dt>\n              <dd className=\"mt-0.5 text-[13.5px] font-medium tracking-[-0.01em] text-foreground @md:text-[14.5px]\">\n                <NumberRoll value={visible ? t.value : 0} format={whole} locales={locales} duration={800} />\n              </dd>\n              <dd className=\"truncate text-[10.5px] text-muted-foreground @md:text-[11px]\">{t.note}</dd>\n            </div>\n          ))}\n        </dl>\n\n        <p aria-live=\"polite\" className=\"sr-only\">\n          {keyboard && active !== null ? `${name(active)}: ${fmt(values[active])}, ${series[1].toLowerCase()} ${fmt(average[active])}` : \"\"}\n        </p>\n        <table className=\"sr-only\">\n          <caption>\n            {title}: {fmt(total)} over {n} days\n          </caption>\n          <thead>\n            <tr>\n              <th scope=\"col\">Day</th>\n              <th scope=\"col\">{series[0]}</th>\n              <th scope=\"col\">{series[1]}</th>\n            </tr>\n          </thead>\n          <tbody>\n            {days.map((d, i) => (\n              <tr key={d.key} id={`${id}-${d.key}`}>\n                <th scope=\"row\">{name(i)}</th>\n                <td>{fmt(d.value)}</td>\n                <td>{fmt(average[i])}</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/combo-chart"}