{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "bar-chart",
  "type": "registry:ui",
  "title": "Bar chart",
  "description": "Compare one measure across days and scrub any bar for its value.",
  "categories": [
    "data"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/motion-tokens.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/components/bar-chart/bar-chart.tsx",
      "content": "\"use client\";\n\nimport {\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n  useSyncExternalStore,\n  type KeyboardEvent,\n  type PointerEvent,\n} from \"react\";\nimport {\n  AnimatePresence,\n  animate,\n  cancelFrame,\n  frame,\n  motion,\n  useInView,\n  useMotionValue,\n  usePresence,\n  useReducedMotion,\n  useTransform,\n  type MotionValue,\n  type Variants,\n} from \"motion/react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport { cn } from \"../lib/utils\";\n\nexport interface BarChartDatum {\n  /** Stable identity, such as an ISO date. A bar whose key survives a data change keeps its place, so switching ranges morphs instead of redrawing. */\n  key: string;\n  /** Full label for the headline and screen readers, such as \"Tue, Sep 15\". */\n  label: string;\n  /** Short label under the bar, such as \"T\" or \"Sep 14\". Leave it out to keep the axis quiet. */\n  axisLabel?: string;\n  value: number;\n}\n\n/** Use a bar chart for one measure across a run of days, weeks, or months, when people compare each period against the rest and against the average. */\nexport interface BarChartProps {\n  data: BarChartDatum[];\n  /** What is measured, such as \"Active minutes\". Names the chart for assistive technology. */\n  label: string;\n  /** The range on show, such as \"Sep 15–21, 2026\". It rests under the headline. */\n  period: string;\n  /** Unit after each value, such as \"min\". */\n  unit?: string;\n  /** Headline label at rest, above the average. */\n  averageLabel?: string;\n  /** Headline label while a bar is scrubbed. */\n  valueLabel?: string;\n  /** Header of the first column in the data table read by screen readers. */\n  categoryLabel?: string;\n  /** Draws the average as a reference line that springs to each new range. */\n  showAverage?: boolean;\n  /** Plot height in pixels. */\n  height?: number;\n  formatValue?: (value: number) => string;\n}\n\nconst { spring, duration, ease, blur, stagger } = motionTokens;\n/** Motion drops inherited velocity on time-defined springs, so values that retarget mid-flight run the same springs written as stiffness and damping. */\nconst physical = ({ visualDuration, bounce }: { visualDuration: number; bounce: number }, restDelta = 0.01) => {\n  const root = (2 * Math.PI) / (visualDuration * 1.2);\n  return {\n    type: \"spring\" as const,\n    stiffness: root * root,\n    damping: 2 * (1 - bounce) * root,\n    restDelta,\n    restSpeed: restDelta * 2,\n  };\n};\nconst settle = physical(spring.smooth);\nconst grow = physical(spring.morph);\nconst glide = physical(spring.snappy);\n/** The zoom runs in fractions of the plot, so it rests far below a pixel. */\nconst zoom = physical(spring.smooth, 0.00001);\nconst collapse = physical({ visualDuration: duration.standard, bounce: 0 });\nconst fadeFast = { duration: duration.instant, ease: [...ease.standard] } as const;\n/** Starts animations on the next frame, so the render that caused them never swallows a spring's first frames. Returns a cleanup. */\nfunction soon(start: () => { stop: () => void }) {\n  let controls: { stop: () => void } | null = null;\n  const run = () => {\n    controls = start();\n  };\n  frame.update(run);\n  return () => {\n    cancelFrame(run);\n    controls?.stop();\n  };\n}\n\n/** Room above the top gridline, the share of each slot a bar fills, its widest size, and the radius of its data end. */\nconst TOP = 12,\n  FILL = 0.58,\n  MAX_BAR = 28,\n  RADIUS = 4;\nconst grouped = new Intl.NumberFormat(\"en-US\", { maximumFractionDigits: 0 });\nconst clamp = (value: number, low: number, high: number) => Math.min(high, Math.max(low, value));\n/** Splits text into user-perceived characters, so a digit or letter never splits into code points. */\nconst graphemes = (text: string) => Array.from(new Intl.Segmenter().segment(text), (part) => part.segment);\n\n/** Clean gridlines: the smallest step of 1, 2, 3, or 5 that covers the data in four rows or fewer, so shared steps survive a range change. */\nfunction scaleFor(max: number) {\n  const safe = Math.max(max, 1);\n  const magnitude = 10 ** Math.floor(Math.log10(safe / 4));\n  for (const factor of [1, 2, 3, 5, 10]) {\n    const step = factor * magnitude,\n      rows = Math.ceil(safe / step);\n    if (rows <= 4) return { top: rows * step, ticks: Array.from({ length: rows + 1 }, (_, row) => row * step) };\n  }\n  return { top: safe, ticks: [0, safe] };\n}\n\n/** A column that grows from a square baseline to a softly rounded data end. */\nfunction barPath(x: number, w: number, h: number, base: number) {\n  if (w <= 0 || h <= 0.01) return `M${x.toFixed(2)} ${base}H${(x + Math.max(w, 0)).toFixed(2)}Z`;\n  const r = Math.min(RADIUS, w / 2, h),\n    top = base - h,\n    f = (value: number) => value.toFixed(2);\n  return `M${f(x)} ${base}V${f(top + r)}A${f(r)} ${f(r)} 0 0 1 ${f(x + r)} ${f(top)}H${f(x + w - r)}A${f(r)} ${f(r)} 0 0 1 ${f(x + w)} ${f(top + r)}V${base}Z`;\n}\n\n/** Slots count back from the latest bar, so the newest day stays pinned right while a longer range zooms out to the left. */\nfunction usePlace(place: number, reduced: boolean) {\n  const value = useMotionValue(place);\n  useEffect(() => {\n    if (value.get() === place) return;\n    if (reduced) {\n      value.jump(place);\n      return;\n    }\n    return soon(() => animate(value, place, settle));\n  }, [place, reduced, value]);\n  return value;\n}\n\ntype Frame = { width: MotionValue<number>; unit: MotionValue<number> };\nconst centerOf = ({ width, unit }: Frame, place: number) => width.get() - (place + 0.5) * width.get() * unit.get();\n\nfunction Bar({\n  id,\n  place,\n  value,\n  delay,\n  shown,\n  active,\n  scrubbing,\n  frame,\n  scale,\n  height,\n  reduced,\n}: {\n  id: string;\n  place: number;\n  value: number;\n  delay: number;\n  shown: boolean;\n  active: boolean;\n  scrubbing: boolean;\n  frame: Frame;\n  scale: MotionValue<number>;\n  height: number;\n  reduced: boolean;\n}) {\n  const [isPresent, safeToRemove] = usePresence();\n  const amount = useMotionValue(0);\n  const slot = usePlace(place, reduced);\n  const [firstDelay] = useState(delay);\n  const grown = useRef(false);\n  const remove = useRef(safeToRemove);\n  useEffect(() => {\n    remove.current = safeToRemove;\n  }, [safeToRemove]);\n\n  // A bar grows once it is seen, each a beat after its neighbour; later values settle without a delay.\n  useEffect(() => {\n    if (!isPresent) return;\n    if (reduced) {\n      amount.jump(value);\n      grown.current = true;\n      return;\n    }\n    if (!shown) return;\n    const first = !grown.current;\n    grown.current = true;\n    return soon(() => animate(amount, value, first ? { ...grow, delay: firstDelay } : settle));\n  }, [amount, firstDelay, isPresent, reduced, shown, value]);\n\n  // A leaving bar sinks into the baseline, faster than a bar grows, while the zoom carries it out of the plot.\n  useEffect(() => {\n    if (isPresent) return;\n    if (reduced) {\n      remove.current?.();\n      return;\n    }\n    return soon(() => animate(amount, 0, { ...collapse, onComplete: () => remove.current?.() }));\n  }, [amount, isPresent, reduced]);\n\n  const d = useTransform(() => {\n    const w = frame.width.get() * frame.unit.get(),\n      size = Math.min(w * FILL, MAX_BAR),\n      center = centerOf(frame, slot.get());\n    return barPath(center - size / 2, size, (amount.get() / scale.get()) * (height - TOP), height);\n  });\n  // The series is drawn in a soft step of the accent; the scrubbed bar takes the full accent while its neighbours step back.\n  // On a dark surface a lighter mix keeps the hue clear instead of turning muddy.\n  const fill = active\n    ? \"fill-accent\"\n    : scrubbing\n      ? \"fill-[color-mix(in_oklab,var(--accent)_24%,var(--surface))] dark:fill-[color-mix(in_oklab,var(--accent)_34%,var(--surface))]\"\n      : \"fill-[color-mix(in_oklab,var(--accent)_62%,var(--surface))] dark:fill-[color-mix(in_oklab,var(--accent)_76%,var(--surface))]\";\n  return (\n    <motion.path\n      className={cn(\"transition-[fill] duration-fast ease-standard motion-reduce:transition-none\", fill)}\n      data-key={id}\n      data-active={active || undefined}\n      d={d}\n    />\n  );\n}\n\n/** A value on the axis: its gridline and label ride the scale, so a taller range slides them down while new rows fade in above. */\nfunction useRowY(value: number, scale: MotionValue<number>, height: number) {\n  return useTransform(() => Math.round(height - (value / scale.get()) * (height - TOP)) + 0.5);\n}\n\nfunction Gridline({ value, scale, height }: { value: number; scale: MotionValue<number>; height: number }) {\n  const y = useRowY(value, scale, height);\n  if (value === 0) return null;\n  return (\n    <motion.line\n      className=\"stroke-line [stroke-width:1] [shape-rendering:crispEdges]\"\n      x1={0}\n      x2=\"100%\"\n      y1={y}\n      y2={y}\n      initial={{ opacity: 0 }}\n      animate={{ opacity: 1 }}\n      exit={{ opacity: 0, transition: fadeFast }}\n      transition={{ duration: duration.standard, ease: [...ease.standard] }}\n    />\n  );\n}\n\nfunction TickLabel({\n  value,\n  scale,\n  height,\n  avoid,\n  format,\n}: {\n  value: number;\n  scale: MotionValue<number>;\n  height: number;\n  avoid: MotionValue<number> | null;\n  format: (value: number) => string;\n}) {\n  const y = useRowY(value, scale, height);\n  // A tick label makes way where the average label sits beside it.\n  const opacity = useTransform(() => (avoid ? clamp((Math.abs(y.get() - avoid.get()) - 10) / 6, 0, 1) : 1));\n  return (\n    <motion.span\n      className=\"pointer-events-none absolute top-0 left-[10px] block h-0 whitespace-nowrap\"\n      style={{ y }}\n      initial={{ opacity: 0 }}\n      animate={{ opacity: 1 }}\n      exit={{ opacity: 0, transition: fadeFast }}\n      transition={{ duration: duration.standard, ease: [...ease.standard] }}\n    >\n      <motion.span\n        className=\"block -translate-y-1/2 text-(length:--text-xs) leading-none text-ink-3 tabular-nums\"\n        style={{ opacity }}\n      >\n        {format(value)}\n      </motion.span>\n    </motion.span>\n  );\n}\n\nfunction AxisLabel({\n  text,\n  size,\n  shift,\n  place,\n  frame,\n  reduced,\n}: {\n  text: string;\n  size: number;\n  shift: number;\n  place: number;\n  frame: Frame;\n  reduced: boolean;\n}) {\n  const slot = usePlace(place, reduced);\n  // Centred under its bar and carried by the zoom. A label at either end keeps the nudge that holds it inside the settled plot.\n  const x = useTransform(() => centerOf(frame, slot.get()) - size / 2 + shift);\n  return (\n    <motion.span\n      className=\"absolute top-2 left-0 text-(length:--text-xs) leading-none whitespace-nowrap text-ink-3 tabular-nums\"\n      style={{ x }}\n      initial={{ opacity: 0 }}\n      animate={{ opacity: 1 }}\n      exit={{ opacity: 0, transition: fadeFast }}\n      transition={{ duration: duration.standard, ease: [...ease.standard] }}\n    >\n      {text}\n    </motion.span>\n  );\n}\n\n/** Axis labels that fit once the range has settled: every label, else every second, third, and so on, counted back from the latest so the newest date always shows and spacing stays even. */\nfunction fittingLabels(data: BarChartDatum[], width: number, sizes: Record<string, number>) {\n  const fitting = new Map<string, number>();\n  if (!width) return fitting;\n  const count = Math.max(1, data.length);\n  const labels = data\n    .flatMap((item, index) => {\n      const size = item.axisLabel ? sizes[item.axisLabel] : undefined;\n      if (size === undefined) return [];\n      const natural = width - (data.length - 1 - index + 0.5) * (width / count) - size / 2;\n      return [{ key: item.key, size, natural, left: clamp(natural, 0, Math.max(0, width - size)) }];\n    })\n    .reverse();\n  for (let stride = 1; stride <= labels.length; stride++) {\n    const picked = labels.filter((_, rank) => rank % stride === 0);\n    if (\n      stride === labels.length ||\n      picked.every((label, rank) => rank === 0 || label.left + label.size + 10 <= picked[rank - 1]!.left)\n    ) {\n      picked.forEach((label) => fitting.set(label.key, label.left - label.natural));\n      break;\n    }\n  }\n  return fitting;\n}\n\n/** Changed text rises from a soft blur in the direction it moved, and leaves a little faster than it arrives. */\nconst rise: Variants = {\n  hidden: (direction: number) => ({ opacity: 0, y: `${0.3 * direction}em`, filter: `blur(${blur.soft}px)` }),\n  shown: { opacity: 1, y: 0, filter: \"blur(0px)\", transition: { duration: duration.standard, ease: [...ease.enter] } },\n  gone: (direction: number) => ({\n    opacity: 0,\n    y: `${-0.3 * direction}em`,\n    filter: `blur(${blur.subtle}px)`,\n    transition: { duration: duration.instant, ease: [...ease.standard] },\n  }),\n};\nconst fade: Variants = {\n  hidden: { opacity: 0, y: 0, filter: \"blur(0px)\" },\n  shown: { opacity: 1, y: 0, filter: \"blur(0px)\", transition: { duration: duration.fast } },\n  gone: { opacity: 0, y: 0, filter: \"blur(0px)\", transition: { duration: duration.instant } },\n};\n\nfunction Swap({ text, direction, reduced }: { text: string; direction: number; reduced: boolean }) {\n  return (\n    <span className=\"relative block min-w-0\">\n      <AnimatePresence mode=\"popLayout\" initial={false} custom={direction}>\n        <motion.span\n          key={text}\n          className=\"block truncate\"\n          custom={direction}\n          variants={reduced ? fade : rise}\n          initial=\"hidden\"\n          animate=\"shown\"\n          exit=\"gone\"\n        >\n          {text}\n        </motion.span>\n      </AnimatePresence>\n    </span>\n  );\n}\n\n/** A number rolls digit by digit, keyed by place from the right, so unchanged digits hold still. Its width springs to fit, so the unit beside it glides instead of jumping. */\nfunction Roll({ text, direction, reduced }: { text: string; direction: number; reduced: boolean }) {\n  const sizer = useRef<HTMLSpanElement>(null);\n  const width = useMotionValue<number | \"auto\">(\"auto\");\n  useLayoutEffect(() => {\n    const node = sizer.current;\n    if (!node || typeof ResizeObserver === \"undefined\") return;\n    let measured: string | null = null;\n    // Only a new value springs; a late web font or a resize jumps straight to fit.\n    const observer = new ResizeObserver(() => {\n      const next = node.offsetWidth;\n      if (measured !== null && measured !== node.textContent && !reduced) animate(width, next, grow);\n      else width.jump(next);\n      measured = node.textContent;\n    });\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, [reduced, width]);\n  const chars = graphemes(text);\n  const variants = reduced ? fade : rise;\n  return (\n    <motion.span\n      className=\"relative inline-flex justify-end overflow-x-clip overflow-y-visible whitespace-pre\"\n      style={{ width }}\n    >\n      <span\n        ref={sizer}\n        className=\"pointer-events-none invisible absolute top-0 left-0 whitespace-pre\"\n        aria-hidden=\"true\"\n      >\n        {text}\n      </span>\n      <span className=\"relative inline-flex flex-none\">\n        <AnimatePresence mode=\"popLayout\" initial={false} custom={direction} anchorX=\"right\">\n          {chars.map((char, index) => (\n            <motion.span\n              key={`p${chars.length - index}`}\n              className=\"relative inline-block whitespace-pre\"\n              custom={direction}\n              variants={variants}\n              initial=\"hidden\"\n              animate=\"shown\"\n              exit=\"gone\"\n            >\n              <AnimatePresence mode=\"popLayout\" initial={false} custom={direction} anchorX=\"right\">\n                <motion.span\n                  key={char}\n                  className=\"relative inline-block whitespace-pre\"\n                  custom={direction}\n                  variants={variants}\n                  initial=\"hidden\"\n                  animate=\"shown\"\n                  exit=\"gone\"\n                >\n                  {char}\n                </motion.span>\n              </AnimatePresence>\n            </motion.span>\n          ))}\n        </AnimatePresence>\n      </span>\n    </motion.span>\n  );\n}\n\nconst subscribeNothing = () => () => {};\n/** Reduced motion is only known in the browser, so the first client render matches the server before it takes effect. */\nfunction useReducedMotionSafe() {\n  const hydrated = useSyncExternalStore(\n    subscribeNothing,\n    () => true,\n    () => false,\n  );\n  return !!useReducedMotion() && hydrated;\n}\n\nexport function BarChart({\n  data,\n  label,\n  period,\n  unit = \"\",\n  averageLabel = \"Daily average\",\n  valueLabel = \"Total\",\n  categoryLabel = \"Day\",\n  showAverage = true,\n  height = 176,\n  formatValue = (value) => grouped.format(value),\n}: BarChartProps) {\n  const reduced = useReducedMotionSafe();\n  const figure = useRef<HTMLElement>(null);\n  const plot = useRef<HTMLDivElement>(null);\n  const inView = useInView(figure, { once: true, amount: 0.35 });\n  const titleId = useId();\n  const count = Math.max(data.length, 1),\n    last = data.length - 1;\n  const average = data.length ? data.reduce((sum, item) => sum + item.value, 0) / data.length : 0;\n  const peak = data.reduce((max, item) => Math.max(max, item.value), 0);\n  const { top, ticks } = useMemo(() => scaleFor(peak), [peak]);\n  const signature = data.map((item) => item.key).join(\"|\");\n  const suffix = unit ? ` ${unit}` : \"\";\n\n  // Bars that join an existing chart grow outward from the ones that stayed; the first reveal runs left to right.\n  const [known, setKnown] = useState(() => ({\n    signature,\n    keys: new Set(data.map((item) => item.key)),\n    fresh: new Map<string, number>(),\n    changes: 0,\n  }));\n  const [active, setActive] = useState<number | null>(null);\n  if (known.signature !== signature) {\n    const incoming = data\n      .map((item, index) => ({ key: item.key, place: last - index }))\n      .filter((item) => !known.keys.has(item.key))\n      .sort((a, b) => a.place - b.place);\n    const step = Math.min(stagger.item, 0.32 / Math.max(1, incoming.length));\n    setKnown({\n      signature,\n      keys: new Set(data.map((item) => item.key)),\n      fresh: new Map(incoming.map((item, rank) => [item.key, rank * step])),\n      changes: known.changes + 1,\n    });\n    setActive(null);\n  }\n  const revealStep = Math.min(stagger.item, 0.36 / count);\n  const index = active === null ? null : Math.min(active, last);\n  const scrubbing = index !== null && last >= 0;\n  const shownValue = scrubbing ? data[index]!.value : average;\n  // Headline copy moves the way the data did: a larger value rises from below, a later day arrives from below too.\n  const [trend, setTrend] = useState({ value: shownValue, index, valueWay: 1, indexWay: 1 });\n  if (trend.value !== shownValue || trend.index !== index)\n    setTrend({\n      value: shownValue,\n      index,\n      valueWay: shownValue === trend.value ? trend.valueWay : shownValue > trend.value ? 1 : -1,\n      indexWay: index === null || trend.index === null ? 1 : Math.sign(index - trend.index) || 1,\n    });\n\n  const width = useMotionValue(0);\n  const unitShare = useMotionValue(1 / count);\n  const scale = useMotionValue(top);\n  const mean = useMotionValue(0);\n  const cursor = useMotionValue(0);\n  const frame = useMemo(() => ({ width, unit: unitShare }), [width, unitShare]);\n\n  // The chart draws in its own pixels; the width is read before paint and followed on resize. Axis labels wait for it, then thin out where they would collide.\n  const [plotWidth, setPlotWidth] = useState(0);\n  const [labelSizes, setLabelSizes] = useState<Record<string, number>>({});\n  const measure = useRef<HTMLSpanElement>(null);\n  const labelTexts = [...new Set(data.map((item) => item.axisLabel).filter((text): text is string => !!text))].join(\n    \"\\n\",\n  );\n  useLayoutEffect(() => {\n    const node = plot.current,\n      ruler = measure.current;\n    if (!node || !ruler) return;\n    const read = () => {\n      width.set(node.clientWidth);\n      setPlotWidth(node.clientWidth);\n      const sizes: Record<string, number> = {};\n      ruler.querySelectorAll<HTMLElement>(\"[data-text]\").forEach((item) => {\n        sizes[item.dataset.text ?? \"\"] = item.offsetWidth;\n      });\n      setLabelSizes((current) => (Object.keys(sizes).every((text) => current[text] === sizes[text]) ? current : sizes));\n    };\n    read();\n    if (typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(read);\n    observer.observe(node);\n    observer.observe(ruler);\n    return () => observer.disconnect();\n  }, [labelTexts, width]);\n  const shownLabels = useMemo(() => fittingLabels(data, plotWidth, labelSizes), [data, plotWidth, labelSizes]);\n\n  // A new range zooms the time axis around the latest bar, rescales the gridlines, and moves the average, all on springs that can be retargeted mid-flight.\n  useEffect(() => {\n    if (reduced) {\n      unitShare.jump(1 / count);\n      scale.jump(top);\n      return;\n    }\n    return soon(() => {\n      const controls = [animate(unitShare, 1 / count, zoom), animate(scale, top, settle)];\n      return { stop: () => controls.forEach((control) => control.stop()) };\n    });\n  }, [count, reduced, scale, top, unitShare]);\n  // The average rises with the first bars, then springs to each new range.\n  const meanStarted = useRef(false);\n  useEffect(() => {\n    if (reduced) {\n      mean.jump(average);\n      return;\n    }\n    if (!inView) return;\n    const first = !meanStarted.current;\n    meanStarted.current = true;\n    return soon(() => animate(mean, average, first ? { ...settle, delay: 0.12 } : settle));\n  }, [average, inView, mean, reduced]);\n\n  // The cursor appears on the first scrubbed bar and glides between the ones after it.\n  const wasScrubbing = useRef(false);\n  useEffect(() => {\n    if (index === null) {\n      wasScrubbing.current = false;\n      return;\n    }\n    const place = last - index;\n    if (reduced || !wasScrubbing.current) cursor.jump(place);\n    wasScrubbing.current = true;\n    if (reduced) return;\n    const controls = animate(cursor, place, glide);\n    return () => controls.stop();\n  }, [cursor, index, last, reduced]);\n\n  const cursorX = useTransform(() => Math.round(centerOf(frame, cursor.get())) + 0.5);\n  const meanY = useTransform(() => Math.round(height - (mean.get() / scale.get()) * (height - TOP)) + 0.5);\n  const meanText = useTransform(() => `Avg ${formatValue(Math.round(mean.get()))}`);\n\n  const pointAt = (clientX: number) => {\n    const rect = plot.current?.getBoundingClientRect();\n    if (!rect?.width) return last;\n    const place = Math.floor(((rect.right - clientX) / rect.width) * count);\n    return clamp(last - place, 0, last);\n  };\n  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    if (event.key === \"Escape\" && scrubbing) {\n      event.preventDefault();\n      setActive(null);\n      return;\n    }\n    const from = index ?? last + 1,\n      page = Math.min(7, count);\n    const next = (\n      {\n        ArrowLeft: from - 1,\n        ArrowDown: from - 1,\n        ArrowRight: index === null ? last : from + 1,\n        ArrowUp: index === null ? last : from + 1,\n        PageDown: from - page,\n        PageUp: from + page,\n        Home: 0,\n        End: last,\n      } as Record<string, number>\n    )[event.key];\n    if (next === undefined || last < 0) return;\n    event.preventDefault();\n    setActive(clamp(next, 0, last));\n  };\n  const onPointerDown = (event: PointerEvent<HTMLDivElement>) => {\n    if (event.pointerType !== \"mouse\") event.currentTarget.setPointerCapture?.(event.pointerId);\n    setActive(pointAt(event.clientX));\n  };\n  const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {\n    if (event.pointerType === \"mouse\" || event.buttons) setActive(pointAt(event.clientX));\n  };\n  const release = (event: PointerEvent<HTMLDivElement>) => {\n    if (event.pointerType !== \"mouse\") setActive(null);\n  };\n\n  const scrubbed = scrubbing ? data[index] : null;\n  const reading = scrubbed ?? data[last];\n  const valueText = reading ? `${reading.label}: ${formatValue(reading.value)}${suffix}` : \"No data\";\n  const highest = data.reduce<BarChartDatum | null>(\n    (best, item) => (!best || item.value > best.value ? item : best),\n    null,\n  );\n  const lowest = data.reduce<BarChartDatum | null>(\n    (best, item) => (!best || item.value < best.value ? item : best),\n    null,\n  );\n  const summary = `${label}, ${period}. ${averageLabel} ${formatValue(Math.round(average))}${suffix}.${highest ? ` Highest ${highest.label}, ${formatValue(highest.value)}${suffix}.` : \"\"}${lowest && lowest !== highest ? ` Lowest ${lowest.label}, ${formatValue(lowest.value)}${suffix}.` : \"\"}`;\n  const shown = inView || reduced;\n  const avoid = showAverage ? meanY : null;\n\n  return (\n    <figure ref={figure} className=\"m-0 grid min-w-0 gap-5 text-ink\" aria-labelledby={titleId}>\n      <figcaption id={titleId} className=\"sr-only\">\n        {label}, {period}\n      </figcaption>\n      <div className=\"grid min-w-0 gap-0.5\" aria-hidden=\"true\">\n        <span className=\"block min-h-[calc(1em*var(--leading-body))] text-sm leading-body text-ink-2\">\n          <Swap text={scrubbing ? valueLabel : averageLabel} direction={1} reduced={reduced} />\n        </span>\n        <span className=\"flex min-w-0 items-baseline gap-[6px] font-display text-3xl leading-display font-medium tracking-display tabular-nums\">\n          <Roll\n            text={formatValue(scrubbing ? data[index]!.value : Math.round(average))}\n            direction={trend.valueWay}\n            reduced={reduced}\n          />\n          {unit && (\n            <span className=\"font-body text-(length:--text-base) font-normal tracking-body text-ink-2\">{unit}</span>\n          )}\n        </span>\n        <span className=\"block min-h-[calc(1em*var(--leading-body))] text-sm leading-body text-ink-3\">\n          <Swap\n            text={scrubbed ? scrubbed.label : period}\n            direction={scrubbing ? trend.indexWay : 1}\n            reduced={reduced}\n          />\n        </span>\n      </div>\n      <div\n        className=\"relative grid min-w-0 grid-cols-[minmax(0,1fr)_52px] grid-rows-[auto_26px]\"\n        data-scrubbing={scrubbing || undefined}\n      >\n        <div ref={plot} className=\"relative min-w-0 [grid-area:1/1]\" style={{ height }}>\n          <svg className=\"block overflow-hidden\" width=\"100%\" height={height} role=\"img\" aria-label={summary}>\n            <AnimatePresence initial={false}>\n              {ticks.map((value) => (\n                <Gridline key={value} value={value} scale={scale} height={height} />\n              ))}\n            </AnimatePresence>\n            <motion.line\n              className=\"stroke-line-strong [stroke-width:1] [shape-rendering:crispEdges]\"\n              x1={cursorX}\n              x2={cursorX}\n              y1={TOP}\n              y2={height}\n              initial={false}\n              animate={{ opacity: scrubbing ? 1 : 0 }}\n              transition={reduced ? { duration: 0 } : fadeFast}\n            />\n            <AnimatePresence initial={false}>\n              {data.map((item, at) => (\n                <Bar\n                  key={item.key}\n                  id={item.key}\n                  place={last - at}\n                  value={item.value}\n                  delay={known.fresh.get(item.key) ?? at * revealStep}\n                  shown={shown}\n                  active={index === at}\n                  scrubbing={scrubbing}\n                  frame={frame}\n                  scale={scale}\n                  height={height}\n                  reduced={reduced}\n                />\n              ))}\n            </AnimatePresence>\n            <line\n              className=\"stroke-line-strong [stroke-width:1] [shape-rendering:crispEdges]\"\n              x1={0}\n              x2=\"100%\"\n              y1={height - 0.5}\n              y2={height - 0.5}\n            />\n            {showAverage && (\n              <motion.line\n                className=\"stroke-ink-2 [stroke-width:1] [stroke-dasharray:3_3] [shape-rendering:crispEdges]\"\n                x1={0}\n                x2=\"100%\"\n                y1={meanY}\n                y2={meanY}\n                initial={false}\n                animate={{ opacity: shown ? 1 : 0 }}\n                transition={reduced ? { duration: 0 } : { duration: duration.standard, delay: 0.12 }}\n              />\n            )}\n          </svg>\n        </div>\n        <div className=\"relative min-w-0 [grid-area:1/2]\" aria-hidden=\"true\">\n          <AnimatePresence initial={false}>\n            {ticks.map((value) => (\n              <TickLabel key={value} value={value} scale={scale} height={height} avoid={avoid} format={formatValue} />\n            ))}\n          </AnimatePresence>\n          {showAverage && (\n            <motion.span\n              className=\"pointer-events-none absolute top-0 left-[10px] block h-0 whitespace-nowrap\"\n              style={{ y: meanY }}\n              initial={false}\n              animate={{ opacity: shown ? 1 : 0 }}\n              transition={reduced ? { duration: 0 } : { duration: duration.standard, delay: 0.12 }}\n            >\n              <motion.span className=\"block -translate-y-1/2 text-(length:--text-xs) leading-none font-medium text-ink tabular-nums\">\n                {meanText}\n              </motion.span>\n            </motion.span>\n          )}\n        </div>\n        <div className=\"relative min-w-0 overflow-hidden [grid-area:2/1]\" aria-hidden=\"true\">\n          <AnimatePresence initial={false}>\n            {data.map((item, at) =>\n              item.axisLabel && shownLabels.has(item.key) ? (\n                <AxisLabel\n                  key={`${item.key}:${item.axisLabel}`}\n                  text={item.axisLabel}\n                  size={labelSizes[item.axisLabel] ?? 0}\n                  shift={shownLabels.get(item.key) ?? 0}\n                  place={last - at}\n                  frame={frame}\n                  reduced={reduced}\n                />\n              ) : null,\n            )}\n          </AnimatePresence>\n          <span ref={measure} className=\"pointer-events-none invisible absolute top-0 left-0 flex\">\n            {labelTexts\n              .split(\"\\n\")\n              .filter(Boolean)\n              .map((text) => (\n                <span\n                  key={text}\n                  className=\"flex-none text-(length:--text-xs) leading-none whitespace-nowrap tabular-nums\"\n                  data-text={text}\n                >\n                  {text}\n                </span>\n              ))}\n          </span>\n        </div>\n        {/* The scrubber lies over the plot: hover or drag across it, or focus it and use the arrow keys. Vertical swipes still scroll the page. */}\n        <div\n          className=\"absolute inset-0 touch-pan-y rounded-[calc(var(--radius-control)/2)] outline-none select-none [-webkit-tap-highlight-color:transparent]\"\n          role=\"slider\"\n          tabIndex={0}\n          aria-label={`${label}, explore by ${categoryLabel.toLowerCase()}`}\n          aria-orientation=\"horizontal\"\n          aria-valuemin={1}\n          aria-valuemax={Math.max(1, data.length)}\n          aria-valuenow={(index ?? last) + 1}\n          aria-valuetext={valueText}\n          onPointerDown={onPointerDown}\n          onPointerMove={onPointerMove}\n          onPointerUp={release}\n          onPointerCancel={() => setActive(null)}\n          onPointerLeave={(event) => {\n            if (event.pointerType === \"mouse\") setActive(null);\n          }}\n          onKeyDown={onKeyDown}\n          onBlur={() => setActive(null)}\n          onFocus={(event) => {\n            if (event.currentTarget.matches(\":focus-visible\") && last >= 0) setActive((current) => current ?? last);\n          }}\n        />\n      </div>\n      <div className=\"sr-only\">\n        <table>\n          <caption>\n            {label}, {period}\n          </caption>\n          <thead>\n            <tr>\n              <th scope=\"col\">{categoryLabel}</th>\n              <th scope=\"col\">{label}</th>\n            </tr>\n          </thead>\n          <tbody>\n            {data.map((item) => (\n              <tr key={item.key}>\n                <th scope=\"row\">{item.label}</th>\n                <td>\n                  {formatValue(item.value)}\n                  {suffix}\n                </td>\n              </tr>\n            ))}\n          </tbody>\n        </table>\n      </div>\n      <p className=\"sr-only\" aria-live=\"polite\" aria-atomic=\"true\">\n        {known.changes ? `${period}. ${averageLabel} ${formatValue(Math.round(average))}${suffix}.` : \"\"}\n      </p>\n    </figure>\n  );\n}\n\nexport default BarChart;\n",
      "type": "registry:component",
      "target": "@components/iris/bar-chart/bar-chart.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/bar-chart",
  "meta": {
    "kind": "component",
    "group": "Charts",
    "export": "BarChart",
    "tags": [
      "chart",
      "bar",
      "data"
    ],
    "docs": "https://irisui.dev/components/bar-chart"
  }
}
