{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"steps-chart","type":"registry:component","title":"Steps chart","description":"A week against a daily goal: days that make it fill in, a wave runs the way you step through the weeks, the totals roll.","author":"Hairline UI","dependencies":[],"registryDependencies":["https://hairlineui.com/r/number-roll.json","https://hairlineui.com/r/text-morph.json"],"files":[{"path":"registry/steps-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 * STEPS CHART: a week of steps against a daily goal\n *\n *   arrive    the first time it's on screen the days grow up out\n *             of the baseline one after another and the total\n *             rolls up from zero\n *   week      ‹ and › step through the weeks: the label morphs,\n *             each day eases to its new height in a wave that\n *             runs the way you went, days that made the goal\n *             turn your primary colour, the totals roll\n *   goal      a line at the goal in your primary colour; days at or over it are\n *             filled, a day still in progress is drawn as an\n *             outline with its fill so far\n *   hover     a hairline column follows the pointer and the\n *             readout at its top slides along, the day morphing\n *             and the steps rolling\n *\n * The plot is one tab stop: arrow keys move between days and the\n * readout is announced. Every day is also in a table for screen\n * readers.\n * ───────────────────────────────────────────────────────── */\n\nexport interface StepsDay {\n  /** The same in every week (e.g. \"mon\"), so a day keeps its bar from week to week. */\n  key: string;\n  /** Under the bar, e.g. \"Mon\". */\n  label: string;\n  value: number;\n  /** In the readout, e.g. \"Thursday 2 October\". Defaults to label. */\n  title?: string;\n  /** A day still under way: drawn as an outline with its fill so far. */\n  today?: boolean;\n}\n\nexport interface StepsWeek {\n  id: string;\n  /** Between the arrows, e.g. \"This week\" or \"22 to 28 Sept\". */\n  label: string;\n  days: StepsDay[];\n}\n\nexport interface StepsChartProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"onChange\"> {\n  /** Oldest first; it opens on the last. */\n  weeks: StepsWeek[];\n  /** Steps a day. */\n  goal?: number;\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (id: string) => void;\n  title?: string;\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 = 176;\nconst HEADROOM = 30;\nconst LABELS = 26;\nconst GUTTER = 40;\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40\";\nconst COMPACT: Intl.NumberFormatOptions = { notation: \"compact\", maximumFractionDigits: 1 };\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 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\nfunction Arrow({ dir, disabled, onClick }: { dir: -1 | 1; disabled: boolean; onClick: () => void }) {\n  return (\n    <button\n      type=\"button\"\n      aria-label={dir < 0 ? \"Previous week\" : \"Next week\"}\n      aria-disabled={disabled || undefined}\n      onClick={() => !disabled && onClick()}\n      className={`flex size-7 items-center justify-center rounded-full text-foreground transition-[background-color,opacity] duration-300 ${FOCUS} ${\n        disabled ? \"cursor-default opacity-30\" : \"hover:bg-accent\"\n      }`}\n    >\n      <svg\n        viewBox=\"0 0 16 16\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeWidth={1.6}\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        aria-hidden=\"true\"\n        className=\"size-3.5\"\n      >\n        <path d={dir < 0 ? \"M10 3.5 5.5 8l4.5 4.5\" : \"M6 3.5 10.5 8 6 12.5\"} />\n      </svg>\n    </button>\n  );\n}\n\nexport function StepsChart({ weeks, goal = 10_000, value, defaultValue, onValueChange, title = \"Steps\", locales, className = \"\", ...props }: StepsChartProps) {\n  const reduced = useReducedMotion();\n  const id = React.useId();\n  const [own, setOwn] = React.useState(defaultValue ?? weeks[weeks.length - 1]?.id);\n  const weekId = value ?? own;\n  const index = Math.max(\n    0,\n    weeks.findIndex((w) => w.id === weekId),\n  );\n  const week = weeks[index];\n  const days = week?.days ?? [];\n\n  // The scale holds still across weeks (the busiest week sets it), so heights compare from week to week.\n  const max = niceMax(Math.max(goal * 1.2, ...weeks.flatMap((w) => w.days.map((d) => d.value))));\n  const total = days.reduce((sum, d) => sum + d.value, 0);\n  // The average leaves out days still to come and a day still under way.\n  const counted = days.filter((d) => d.value > 0 && (!d.today || d.value >= goal));\n  const average = counted.length ? Math.round(counted.reduce((sum, d) => sum + d.value, 0) / counted.length) : 0;\n  const met = days.filter((d) => d.value >= goal).length;\n  const slot = days.length ? 100 / days.length : 100;\n  const scale = (n: number) => (n / max) * (plotH - HEADROOM);\n\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 [dir, setDir] = React.useState(1);\n  const [shownIndex, setShownIndex] = React.useState(index);\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const barsRef = React.useRef<HTMLDivElement>(null);\n  const plotRef = React.useRef<HTMLDivElement>(null);\n\n  // The wave runs the way you went: back in time from the right, forward from the left.\n  if (shownIndex !== index) {\n    setDir(index > shownIndex ? 1 : -1);\n    setShownIndex(index);\n  }\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  const go = (d: -1 | 1) => {\n    const next = weeks[index + d];\n    if (!next) return;\n    if (value === undefined) setOwn(next.id);\n    onValueChange?.(next.id);\n  };\n\n  // Days still to come can't be read: the readout stops at today.\n  const lastDay = Math.max(0, days.reduce((last, d, i) => (d.value > 0 || d.today ? i : last), -1));\n  const pointAt = (clientX: number) => {\n    const r = barsRef.current?.getBoundingClientRect();\n    if (!r || !days.length) return null;\n    return Math.min(lastDay, Math.max(0, Math.floor(((clientX - r.left) / r.width) * days.length)));\n  };\n\n  const onKeyDown = (e: React.KeyboardEvent) => {\n    if (!days.length) return;\n    const from = active ?? Math.max(0, days.findIndex((d) => d.today) === -1 ? days.length - 1 : days.findIndex((d) => d.today));\n    const moves: Record<string, number> = { ArrowLeft: from - 1, ArrowRight: from + 1, Home: 0, End: lastDay };\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(lastDay, Math.max(0, active === null ? from : moves[e.key])));\n  };\n\n  const fmt = (n: number) => new Intl.NumberFormat(locales).format(n);\n  const shown = active !== null ? days[active] : null;\n  const goalTop = HEADROOM + (plotH - HEADROOM) - scale(goal);\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 the week share a row; the figures sit under both. */}\n        <div className=\"flex items-center justify-between gap-3\">\n          <p className=\"truncate text-[12px] text-muted-foreground @md:text-[13px]\">{title}</p>\n          <div className=\"-my-1 -mr-1.5 flex shrink-0 items-center\">\n            <Arrow dir={-1} disabled={index === 0} onClick={() => go(-1)} />\n            <span className=\"min-w-[78px] px-0.5 text-center text-[11.5px] font-medium text-foreground @md:min-w-[92px] @md:text-[12.5px]\" aria-live=\"polite\">\n              <TextMorph direction={dir > 0 ? \"up\" : \"down\"}>{week?.label ?? \"\"}</TextMorph>\n            </span>\n            <Arrow dir={1} disabled={index === weeks.length - 1} onClick={() => go(1)} />\n          </div>\n        </div>\n        <p className=\"mt-0.5 flex items-baseline gap-1.5 text-[22px] font-medium leading-tight tracking-[-0.03em] text-foreground @md:text-[26px]\">\n          <NumberRoll value={visible ? total : 0} locales={locales} duration={1000} />\n          <span className=\"text-[12px] font-normal tracking-normal text-muted-foreground\">steps</span>\n        </p>\n        <p className=\"mt-1 flex flex-wrap items-baseline gap-x-3 gap-y-0.5 text-[11.5px] text-muted-foreground @md:text-[12.5px]\">\n          <span className=\"flex items-baseline gap-[0.3em]\">\n            <NumberRoll value={visible ? average : 0} locales={locales} duration={800} className=\"text-foreground\" />a day\n          </span>\n          <span className=\"flex items-baseline gap-[0.3em]\">\n            Goal met on\n            <NumberRoll value={visible ? met : 0} duration={700} className=\"text-foreground\" />\n            of {days.length} days\n          </span>\n        </p>\n\n        <div\n          ref={plotRef}\n          role=\"group\"\n          tabIndex={0}\n          aria-label={`${title}, ${week?.label ?? \"\"}. 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={`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: the top and the baseline, and the goal as a line in your primary colour. */}\n          {[1, 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-[10px] leading-none tabular-nums text-muted-foreground/80\"\n                style={{ width: \"calc(var(--gutter) - 8px)\", textAlign: \"right\" }}\n              >\n                <NumberRoll value={max * f} format={COMPACT} locales={locales} duration={700} />\n              </span>\n              <div className=\"border-t border-border\" style={{ marginLeft: \"var(--gutter)\" }} />\n            </div>\n          ))}\n          <div aria-hidden=\"true\" className=\"absolute right-0\" style={{ left: 0, top: goalTop }}>\n            <span\n              className=\"absolute left-0 top-0 -translate-y-1/2 text-[10px] font-medium leading-none tabular-nums text-primary\"\n              style={{ width: \"calc(var(--gutter) - 8px)\", textAlign: \"right\" }}\n            >\n              <NumberRoll value={goal} format={COMPACT} locales={locales} duration={700} />\n            </span>\n            <div className=\"border-t border-primary/60\" style={{ marginLeft: \"var(--gutter)\" }} />\n          </div>\n\n          <div ref={barsRef} className=\"absolute inset-y-0 right-0\" style={{ left: \"var(--gutter)\" }}>\n            {/* The hovered day: 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: active !== null ? `calc(${active * slot}% + 2px)` : 0,\n                width: active !== null ? `calc(${slot}% - 4px)` : 0,\n                opacity: active !== null ? 1 : 0,\n                transition: reduced ? \"none\" : `left 380ms ${THROW}, width 380ms ${EASE}, opacity 200ms ${EASE}`,\n              }}\n            />\n\n            {days.map((d, i) => {\n              const made = d.value >= goal;\n              const hovered = active === i;\n              const order = dir > 0 ? i : days.length - 1 - i;\n              const tone = d.today\n                ? made\n                  ? \"bg-primary\"\n                  : \"bg-primary/15 shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--primary)_55%,transparent)]\"\n                : made\n                  ? hovered || active === null\n                    ? \"bg-primary\"\n                    : \"bg-primary/70\"\n                  : hovered\n                    ? \"bg-foreground/25\"\n                    : active !== null\n                      ? \"bg-foreground/[0.07]\"\n                      : \"bg-foreground/[0.11]\";\n              return (\n                <div key={d.key} aria-hidden=\"true\" className=\"absolute top-0 h-full\" style={{ left: `${i * slot}%`, width: `${slot}%` }}>\n                  <div\n                    className={`absolute bottom-[26px] left-1/2 w-[min(24px,58%)] -translate-x-1/2 rounded-t-[4px] ${tone}`}\n                    style={{\n                      // Days still to come have no bar at all.\n                      height: visible && d.value > 0 ? Math.max(2, scale(d.value)) : 0,\n                      transition: reduced ? \"none\" : `height 620ms ${EASE} ${order * 34}ms, background-color 320ms ${EASE}, box-shadow 320ms ${EASE}`,\n                    }}\n                  />\n                  <span\n                    className={`absolute inset-x-0 bottom-0 text-center text-[11px] leading-[18px] ${d.today ? \"font-medium text-foreground\" : \"text-muted-foreground\"}`}\n                  >\n                    {d.label}\n                  </span>\n                </div>\n              );\n            })}\n\n            {/* The readout rides the top of the lane: the day morphs, the steps roll. */}\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: active !== null ? `clamp(64px, ${(active + 0.5) * slot}%, calc(100% - 64px))` : \"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} 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)} steps${shown.value >= goal ? \", goal met\" : \"\"}` : \"\"}\n        </p>\n        <table className=\"sr-only\">\n          <caption>\n            {title}, {week?.label}: {fmt(total)} steps, goal {fmt(goal)} a day, met on {met} of {days.length} days\n          </caption>\n          <tbody>\n            {days.map((d) => (\n              <tr key={d.key} id={`${id}-${d.key}`}>\n                <th scope=\"row\">{d.title ?? d.label}</th>\n                <td>\n                  {fmt(d.value)}\n                  {d.today ? \" so far\" : \"\"}\n                </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/steps-chart"}