{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "usage-meter",
  "type": "registry:ui",
  "title": "Usage meter",
  "description": "Show what fills an allowance and how close it is to the limit.",
  "categories": [
    "feedback"
  ],
  "dependencies": [
    "lucide-react",
    "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/usage-meter/usage-meter.tsx",
      "content": "\"use client\";\n\nimport {\n  useEffect,\n  useId,\n  useLayoutEffect,\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  motionValue,\n  useInView,\n  useMotionValue,\n  useReducedMotion,\n  useTransform,\n  type AnimationPlaybackControls,\n  type MotionValue,\n  type Variants,\n} from \"motion/react\";\nimport { CircleAlert, TriangleAlert } from \"lucide-react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport { cn } from \"../lib/utils\";\n\nexport interface UsageMeterSegment {\n  /** Stable identity. Keep it the same while the value changes, so the segment re-flows instead of being replaced. */\n  id: string;\n  label: string;\n  value: number;\n}\n\n/** Use a usage meter for a fixed allowance, such as storage or seats, when people need to see what takes the space and how close they are to the limit. */\nexport interface UsageMeterProps {\n  /** What is measured, such as \"Workspace storage\". */\n  label: string;\n  /** Used amounts in display order. Up to four read clearly. */\n  segments: UsageMeterSegment[];\n  /** The plan limit, in the same unit as the segments. */\n  limit: number;\n  unit?: string;\n  /** Digits after the decimal point. */\n  decimals?: number;\n  freeLabel?: string;\n  overLabel?: string;\n  /** Share of the limit at which the meter warns that it is almost full. */\n  warnAt?: number;\n}\n\nconst { spring, duration, ease, blur } = 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.001) => {\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 reveal = physical({ visualDuration: duration.considered + 0.1, bounce: 0 });\nconst turn = physical(spring.smooth);\nconst fit = physical(spring.morph, 0.01);\n/** A segment stops two pixels short of the next, so neighbours read apart without an outline. */\nconst GAP = 2;\n/** Categories are steps of the accent (--segment-0…3 on the meter), strongest first, so the meter reads as one allowance rather than a rainbow. */\nconst segmentFill = [\"bg-(--segment-0)\", \"bg-(--segment-1)\", \"bg-(--segment-2)\", \"bg-(--segment-3)\"];\n/** Free space is the track itself. */\nconst trackFill = \"bg-[color-mix(in_oklab,var(--ink)_7%,var(--surface))]\";\nconst srOnly = \"absolute size-px overflow-hidden whitespace-nowrap [clip-path:inset(50%)]\";\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 { reduced: !!useReducedMotion() && hydrated, hydrated };\n}\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/** Changed text rises from a soft blur and leaves a little faster than it arrives. Icons cross through a small scale instead. */\nconst rise: Variants = {\n  hidden: (direction: number) => ({ opacity: 0, y: `${0.3 * direction}em`, scale: 1, filter: `blur(${blur.soft}px)` }),\n  shown: {\n    opacity: 1,\n    y: 0,\n    scale: 1,\n    filter: \"blur(0px)\",\n    transition: { duration: duration.standard, ease: [...ease.enter] },\n  },\n  gone: (direction: number) => ({\n    opacity: 0,\n    y: `${-0.3 * direction}em`,\n    scale: 1,\n    filter: `blur(${blur.subtle}px)`,\n    transition: { duration: duration.instant, ease: [...ease.standard] },\n  }),\n};\nconst pop: Variants = {\n  hidden: { opacity: 0, y: 0, scale: 0.6, filter: `blur(${blur.subtle + 1}px)` },\n  shown: {\n    opacity: 1,\n    y: 0,\n    scale: 1,\n    filter: \"blur(0px)\",\n    transition: {\n      ...spring.snappy,\n      opacity: { duration: duration.fast, ease: [...ease.enter] },\n      filter: { duration: duration.fast, ease: [...ease.enter] },\n    },\n  },\n  gone: {\n    opacity: 0,\n    y: 0,\n    scale: 0.6,\n    filter: `blur(${blur.subtle + 1}px)`,\n    transition: { duration: duration.instant, ease: [...ease.standard] },\n  },\n};\nconst fade: Variants = {\n  hidden: { opacity: 0, y: 0, scale: 1, filter: \"blur(0px)\" },\n  shown: { opacity: 1, y: 0, scale: 1, filter: \"blur(0px)\", transition: { duration: duration.fast } },\n  gone: { opacity: 0, y: 0, scale: 1, filter: \"blur(0px)\", transition: { duration: duration.instant } },\n};\n\nfunction Swap({ text, reduced, direction = 1 }: { text: string; reduced: boolean; direction?: number }) {\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 overflow-hidden text-ellipsis whitespace-nowrap\"\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\ntype Part = { key: string; digit: number } | { key: string; text: string };\nconst formats = new Map<number, Intl.NumberFormat>();\n/** Split a number into columns keyed by place value, so 9.8 → 10.4 keeps the ones column the ones column and a new tens column slides in. */\nfunction partsFor(value: number, decimals: number): Part[] {\n  let format = formats.get(decimals);\n  if (!format) {\n    format = new Intl.NumberFormat(\"en-US\", { minimumFractionDigits: decimals, maximumFractionDigits: decimals });\n    formats.set(decimals, format);\n  }\n  const parts = format.formatToParts(value);\n  let place = parts.reduce((count, part) => count + (part.type === \"integer\" ? part.value.length : 0), 0),\n    fraction = 0;\n  return parts.flatMap((part, index): Part[] => {\n    if (part.type === \"integer\")\n      return part.value.split(\"\").map((char) => ({ key: `i${--place}`, digit: Number(char) }));\n    if (part.type === \"fraction\")\n      return part.value.split(\"\").map((char) => ({ key: `f${fraction++}`, digit: Number(char) }));\n    return [\n      {\n        key: part.type === \"group\" ? `g${place}` : part.type === \"decimal\" ? \"d\" : `${part.type}${index}`,\n        text: part.value,\n      },\n    ];\n  });\n}\nconst DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];\n\n/** One digit on a wheel; its distance from the wheel position sets where it sits and how clearly it shows. */\nfunction Glyph({ position, digit }: { position: MotionValue<number>; digit: number }) {\n  const offset = useTransform(position, (current) => ((((digit - current) % 10) + 15) % 10) - 5);\n  const y = useTransform(offset, (current) => `${current}em`);\n  const opacity = useTransform(offset, (current) => Math.max(0, 1 - Math.abs(current)));\n  const filter = useTransform(offset, (current) =>\n    Math.abs(current) < 0.02 || Math.abs(current) >= 1\n      ? \"none\"\n      : `blur(${(Math.abs(current) * blur.subtle).toFixed(2)}px)`,\n  );\n  return (\n    <motion.span className=\"absolute inset-x-0 inset-y-(--feather) text-center\" style={{ y, opacity, filter }}>\n      {digit}\n    </motion.span>\n  );\n}\n\nconst column = {\n  initial: { width: 0, opacity: 0 },\n  animate: { width: \"auto\", opacity: 1 },\n  exit: { width: 0, opacity: 0 },\n};\n\n/** A digit wheel that always turns the way the whole number moved, wrapping 9 → 0 like an odometer. */\nfunction Column({ digit, direction, reduced }: { digit: number; direction: number; reduced: boolean }) {\n  const position = useMotionValue(digit);\n  const wheel = useRef({ digit, target: digit });\n  const running = useRef<(() => void) | null>(null);\n  useEffect(() => () => running.current?.(), []);\n  useEffect(() => {\n    const state = wheel.current;\n    if (state.digit === digit) return;\n    state.target += direction < 0 ? -((state.digit - digit + 10) % 10) : (digit - state.digit + 10) % 10;\n    state.digit = digit;\n    running.current?.();\n    if (reduced) {\n      position.jump(state.target);\n      running.current = null;\n      return;\n    }\n    const target = state.target;\n    running.current = soon(() => animate(position, target, turn));\n  }, [digit, direction, position, reduced]);\n  return (\n    <motion.span\n      className=\"relative -my-(--feather) inline-block overflow-hidden py-(--feather) [--feather:.16em] [mask-image:linear-gradient(to_bottom,transparent,#000_calc(var(--feather)_*_1.5),#000_calc(100%_-_var(--feather)_*_1.5),transparent)]\"\n      {...column}\n      transition={reduced ? { duration: 0 } : fit}\n    >\n      <span className=\"invisible\">0</span>\n      {DIGITS.map((item) => (\n        <Glyph key={item} position={position} digit={item} />\n      ))}\n    </motion.span>\n  );\n}\n\n/** A number that rolls to each new value. It is decoration over text that screen readers get elsewhere. */\nfunction Ticker({ value, decimals, reduced }: { value: number; decimals: number; reduced: boolean }) {\n  const [trend, setTrend] = useState({ value, direction: 1 });\n  if (trend.value !== value) setTrend({ value, direction: value > trend.value ? 1 : -1 });\n  return (\n    <span className=\"relative inline-flex items-start whitespace-nowrap tabular-nums select-none\" aria-hidden=\"true\">\n      <AnimatePresence initial={false}>\n        {partsFor(value, decimals).map((part) =>\n          \"digit\" in part ? (\n            <Column key={part.key} digit={part.digit} direction={trend.direction} reduced={reduced} />\n          ) : (\n            <motion.span\n              key={part.key}\n              className=\"inline-block overflow-x-clip\"\n              {...column}\n              transition={reduced ? { duration: 0 } : fit}\n            >\n              {part.text}\n            </motion.span>\n          ),\n        )}\n      </AnimatePresence>\n    </span>\n  );\n}\n\ntype Status = \"ok\" | \"near\" | \"over\";\n\n/** The plan status as a compact mark. Its width springs to each new message and the warning icon grows in beside the words. */\nfunction StatusBadge({ status, text, reduced }: { status: Status; text: string; reduced: boolean }) {\n  const inner = useRef<HTMLSpanElement>(null);\n  const width = useMotionValue<number | \"auto\">(\"auto\");\n  useLayoutEffect(() => {\n    const node = inner.current;\n    if (!node || typeof ResizeObserver === \"undefined\") return;\n    let measured = false;\n    const observer = new ResizeObserver(() => {\n      const next = node.offsetWidth;\n      if (measured && !reduced) animate(width, next, fit);\n      else width.jump(next);\n      measured = next > 0;\n    });\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, [reduced, width]);\n  const Icon = status === \"over\" ? TriangleAlert : CircleAlert;\n  return (\n    <motion.span\n      className={cn(\n        \"inline-flex h-7 flex-none justify-end overflow-hidden rounded-full text-(length:--text-xs) leading-none font-medium transition-[background-color,color] duration-standard ease-standard motion-reduce:transition-none\",\n        status === \"over\"\n          ? \"bg-[color-mix(in_oklab,var(--red)_12%,var(--surface))] text-ink\"\n          : status === \"near\"\n            ? \"bg-[color-mix(in_oklab,var(--orange)_14%,var(--surface))] text-ink\"\n            : \"bg-hover text-ink-2\",\n      )}\n      data-status={status}\n      style={{ width }}\n    >\n      <span ref={inner} className=\"inline-flex flex-none items-center gap-1.5 px-3 py-0 whitespace-nowrap\">\n        <AnimatePresence mode=\"popLayout\" initial={false}>\n          {status !== \"ok\" && (\n            <motion.span\n              key={status}\n              className={cn(\"grid place-items-center\", status === \"over\" ? \"text-red\" : \"text-orange\")}\n              variants={reduced ? fade : pop}\n              initial=\"hidden\"\n              animate=\"shown\"\n              exit=\"gone\"\n            >\n              <Icon size={14} strokeWidth={2} aria-hidden=\"true\" />\n            </motion.span>\n          )}\n        </AnimatePresence>\n        <Swap text={text} reduced={reduced} />\n      </span>\n    </motion.span>\n  );\n}\n\nfunction Segment({\n  index,\n  amounts,\n  span,\n  width,\n  highlight,\n}: {\n  index: number;\n  amounts: MotionValue<number>[];\n  span: MotionValue<number>;\n  width: MotionValue<number>;\n  highlight: \"on\" | \"off\" | undefined;\n}) {\n  // Each segment starts where the ones before it end, so growth in any of them pushes the rest along in the same frame.\n  const x = useTransform(() => {\n    let start = 0;\n    for (let at = 0; at < index; at++) start += amounts[at]!.get();\n    return (start / span.get()) * width.get();\n  });\n  const scaleX = useTransform(() => {\n    const w = width.get();\n    if (!w) return 0;\n    const size = (amounts[index]!.get() / span.get()) * w,\n      rest = w - x.get() - size;\n    return Math.max(0, size - Math.min(GAP, size, Math.max(0, rest))) / w;\n  });\n  return (\n    <motion.span\n      className={cn(\n        \"absolute inset-0 transition-opacity duration-fast ease-standard data-[highlight=off]:opacity-28 motion-reduce:transition-none\",\n        segmentFill[index],\n      )}\n      data-index={index}\n      data-highlight={highlight}\n      style={{ x, scaleX, originX: 0 }}\n    />\n  );\n}\n\nexport function UsageMeter({\n  label,\n  segments,\n  limit,\n  unit = \"\",\n  decimals = 1,\n  freeLabel = \"Free\",\n  overLabel = \"Over limit\",\n  warnAt = 0.9,\n}: UsageMeterProps) {\n  const { reduced, hydrated } = useReducedMotionSafe();\n  const root = useRef<HTMLDivElement>(null);\n  const trackRef = useRef<HTMLDivElement>(null);\n  const inView = useInView(root, { once: true, amount: 0.4 });\n  const titleId = useId();\n  const hatchId = `hatch${titleId.replace(/[^a-zA-Z0-9_-]/g, \"\")}`;\n  const ready = hydrated && (inView || reduced);\n  const round = (value: number) => Math.round(value * 10 ** decimals) / 10 ** decimals;\n  const total = round(segments.reduce((sum, segment) => sum + segment.value, 0));\n  const free = round(Math.max(0, limit - total)),\n    over = round(Math.max(0, total - limit));\n  const status: Status = total > limit ? \"over\" : total >= limit * warnAt ? \"near\" : \"ok\";\n  const format = (value: number) => `${value.toFixed(decimals)}${unit ? ` ${unit}` : \"\"}`;\n  const limitText = format(limit).replace(/\\.0+(?= |$)/, \"\");\n\n  // One motion value per segment id, kept across renders so a changed value springs from where it is.\n  const [store] = useState(() => new Map<string, MotionValue<number>>());\n  const amounts = segments.map((segment) => {\n    let value = store.get(segment.id);\n    if (!value) {\n      value = motionValue(0);\n      store.set(segment.id, value);\n    }\n    return value;\n  });\n  const shownLimit = useMotionValue(limit);\n  const width = useMotionValue(0);\n  const used = useTransform(() => amounts.reduce((sum, amount) => sum + amount.get(), 0));\n  // The bar spans the limit, or everything used once that is more; both follow their springs, so crossing the limit is continuous.\n  const span = useTransform(() => Math.max(shownLimit.get(), used.get(), 1e-6));\n  const limitX = useTransform(() => Math.round((shownLimit.get() / span.get()) * width.get()));\n  // The limit marker and the hatch fade on their own short tween whenever usage crosses the limit, so a limit that jumps past usage never blinks them out in one frame.\n  const markerOpacity = useMotionValue(0);\n  useEffect(() => {\n    let over = used.get() > shownLimit.get();\n    markerOpacity.jump(over ? 1 : 0);\n    let fading: AnimationPlaybackControls | undefined;\n    const check = () => {\n      const next = used.get() > shownLimit.get();\n      if (next === over) return;\n      over = next;\n      fading?.stop();\n      if (reduced) markerOpacity.jump(next ? 1 : 0);\n      else fading = animate(markerOpacity, next ? 1 : 0, { duration: duration.fast, ease: [...ease.standard] });\n    };\n    const stops = [used.on(\"change\", check), shownLimit.on(\"change\", check)];\n    return () => {\n      stops.forEach((stop) => stop());\n      fading?.stop();\n    };\n  }, [markerOpacity, reduced, shownLimit, used]);\n  const overClip = useTransform(() => `inset(0 0 0 ${limitX.get() + GAP}px)`);\n\n  useLayoutEffect(() => {\n    const node = trackRef.current;\n    if (!node) return;\n    width.set(node.clientWidth);\n    if (typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(() => width.set(node.clientWidth));\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, [width]);\n\n  // Segments grow into place once seen, left to right; later changes re-flow every segment on one spring each.\n  const values = segments.map((segment) => `${segment.id}:${segment.value}`).join(\"|\");\n  const revealed = useRef(false);\n  useEffect(() => {\n    if (!ready) return;\n    const first = !revealed.current;\n    revealed.current = true;\n    const stops = segments.map((segment, index) => {\n      const amount = store.get(segment.id);\n      if (!amount || amount.get() === segment.value) return null;\n      if (reduced) {\n        amount.jump(segment.value);\n        return null;\n      }\n      return soon(() => animate(amount, segment.value, first ? { ...reveal, delay: index * 0.07 } : settle));\n    });\n    return () => stops.forEach((stop) => stop?.());\n    // `values` carries every segment id and value.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [values, ready, reduced, store]);\n  useEffect(() => {\n    if (shownLimit.get() === limit) return;\n    if (reduced) {\n      shownLimit.jump(limit);\n      return;\n    }\n    return soon(() => animate(shownLimit, limit, settle));\n  }, [limit, reduced, shownLimit]);\n\n  // Hover previews a category, focus previews it too, and a press pins it until pressed again or Escape.\n  const [hovered, setHovered] = useState<string | null>(null);\n  const [focused, setFocused] = useState<string | null>(null);\n  const [pinned, setPinned] = useState<string | null>(null);\n  const [roving, setRoving] = useState(0);\n  const items = [\n    ...segments.map((segment) => ({ id: segment.id, label: segment.label, value: segment.value })),\n    { id: \"free\", label: status === \"over\" ? overLabel : freeLabel, value: status === \"over\" ? over : free },\n  ];\n  const active = hovered ?? focused ?? pinned;\n  const activeItem = items.find((item) => item.id === active) ?? null;\n  const legend = useRef<HTMLDivElement>(null);\n\n  const idAt = (clientX: number) => {\n    const rect = trackRef.current?.getBoundingClientRect();\n    if (!rect?.width) return null;\n    const at = ((clientX - rect.left) / rect.width) * Math.max(limit, total);\n    // Past the limit the hatch stands for the overage, so pointing there highlights it rather than the category beneath.\n    if (at > limit) return \"free\";\n    let end = 0;\n    for (const segment of segments) {\n      end += segment.value;\n      if (at <= end) return segment.id;\n    }\n    return \"free\";\n  };\n  const onTrackMove = (event: PointerEvent<HTMLDivElement>) => {\n    if (event.pointerType === \"mouse\") setHovered(idAt(event.clientX));\n  };\n  const onTrackTap = (event: PointerEvent<HTMLDivElement>) => {\n    if (event.pointerType !== \"mouse\") {\n      const id = idAt(event.clientX);\n      setPinned((current) => (current === id ? null : id));\n    }\n  };\n  const onLegendKey = (event: KeyboardEvent<HTMLDivElement>) => {\n    if (event.key === \"Escape\") {\n      if (pinned || focused) {\n        event.preventDefault();\n        setPinned(null);\n      }\n      return;\n    }\n    const next = (\n      {\n        ArrowRight: roving + 1,\n        ArrowDown: roving + 1,\n        ArrowLeft: roving - 1,\n        ArrowUp: roving - 1,\n        Home: 0,\n        End: items.length - 1,\n      } as Record<string, number>\n    )[event.key];\n    if (next === undefined) return;\n    event.preventDefault();\n    const index = (next + items.length) % items.length;\n    setRoving(index);\n    legend.current?.querySelectorAll<HTMLButtonElement>(\"button\")[index]?.focus();\n  };\n\n  const headline = ready ? (activeItem ? activeItem.value : total) : 0;\n  const share = activeItem ? Math.round((activeItem.value / limit) * 100) : 0;\n  const caption = !activeItem\n    ? `${unit} of ${limitText} used`\n    : activeItem.id === \"free\"\n      ? `${unit} ${status === \"over\" ? \"over the limit\" : \"free\"}`\n      : `${unit} in ${activeItem.label} · ${share}% of plan`;\n  const badge = status === \"over\" ? `${format(over)} over` : status === \"near\" ? \"Almost full\" : `${format(free)} free`;\n  const summary = `${label}: ${format(total)} of ${limitText} used. ${segments.map((segment) => `${segment.label} ${format(segment.value)}`).join(\", \")}. ${status === \"over\" ? `Over the limit by ${format(over)}.` : `${format(free)} free${status === \"near\" ? \", almost full\" : \"\"}.`}`;\n  // Status changes are announced, and so is a growing overage; routine changes inside a status stay quiet.\n  const notice = status === \"over\" ? `over:${over}` : status;\n  const [announced, setAnnounced] = useState({ notice, message: \"\" });\n  if (announced.notice !== notice)\n    setAnnounced({\n      notice,\n      message:\n        status === \"over\"\n          ? `Over the plan limit by ${format(over)}.`\n          : status === \"near\"\n            ? `Almost full. ${format(free)} free.`\n            : `Under the limit. ${format(free)} free.`,\n    });\n\n  return (\n    <div\n      ref={root}\n      className=\"grid min-w-0 gap-4 text-ink [--segment-0:var(--accent)] [--segment-1:color-mix(in_oklab,var(--accent)_62%,var(--surface))] [--segment-2:color-mix(in_oklab,var(--accent)_36%,var(--surface))] [--segment-3:color-mix(in_oklab,var(--accent)_22%,var(--surface))]\"\n      role=\"group\"\n      aria-labelledby={titleId}\n      data-status={status}\n    >\n      <div className=\"flex min-h-7 min-w-0 items-center justify-between gap-3\">\n        <span\n          id={titleId}\n          className=\"min-w-0 text-(length:--text-base) leading-body font-medium tracking-body text-balance text-ink\"\n        >\n          {label}\n        </span>\n        <StatusBadge status={status} text={badge} reduced={reduced} />\n      </div>\n      <p className=\"m-0 flex min-w-0 items-baseline gap-2\">\n        <span className={srOnly}>\n          {format(total)} of {limitText} used\n        </span>\n        <span\n          className=\"flex-none font-display text-(length:--text-3xl) leading-display font-medium tracking-display\"\n          aria-hidden=\"true\"\n        >\n          <Ticker value={headline} decimals={decimals} reduced={reduced} />\n        </span>\n        <span className=\"min-w-0 flex-1 text-(length:--text-sm) text-ink-2\" aria-hidden=\"true\">\n          <Swap text={caption} reduced={reduced} />\n        </span>\n      </p>\n      <div className=\"relative py-[5px]\">\n        <div\n          ref={trackRef}\n          className={cn(\n            \"relative isolate h-3.5 overflow-hidden rounded-full [-webkit-tap-highlight-color:transparent]\",\n            trackFill,\n          )}\n          role=\"img\"\n          aria-label={summary}\n          data-active={active ?? undefined}\n          onPointerMove={onTrackMove}\n          onPointerLeave={() => setHovered(null)}\n          onPointerUp={onTrackTap}\n        >\n          {segments.map((segment, index) => (\n            <Segment\n              key={segment.id}\n              index={index}\n              amounts={amounts}\n              span={span}\n              width={width}\n              highlight={active ? (active === segment.id ? \"on\" : \"off\") : undefined}\n            />\n          ))}\n          {/* Past the limit the bar is struck through: the overflow keeps its colours, hatched so it reads as over without colour. */}\n          <motion.svg\n            className=\"pointer-events-none absolute inset-0 size-full\"\n            data-highlight={active ? (active === \"free\" ? \"on\" : \"off\") : undefined}\n            style={{ clipPath: overClip, opacity: markerOpacity }}\n            aria-hidden=\"true\"\n            focusable=\"false\"\n          >\n            <defs>\n              <pattern id={hatchId} width={6} height={6} patternUnits=\"userSpaceOnUse\" patternTransform=\"rotate(45)\">\n                <rect width={2.5} height={6} className=\"fill-surface\" />\n              </pattern>\n            </defs>\n            <rect\n              className={cn(\n                \"fill-red transition-opacity duration-fast ease-standard motion-reduce:transition-none\",\n                active === \"free\" ? \"opacity-100\" : \"opacity-0\",\n              )}\n              width=\"100%\"\n              height=\"100%\"\n            />\n            <rect width=\"100%\" height=\"100%\" fill={`url(#${hatchId})`} />\n          </motion.svg>\n        </div>\n        <motion.span\n          className=\"pointer-events-none absolute top-0 bottom-0 -left-px w-0.5 rounded-[1px] bg-ink\"\n          style={{ x: limitX, opacity: markerOpacity }}\n          aria-hidden=\"true\"\n        />\n      </div>\n      {/* oxlint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- a group of legend buttons with delegated arrow-key navigation, not a form */}\n      <div\n        ref={legend}\n        className=\"-mx-2 my-0 grid grid-cols-[repeat(auto-fit,minmax(108px,1fr))] gap-0.5\"\n        role=\"group\"\n        aria-label={`${label} by category`}\n        onKeyDown={onLegendKey}\n      >\n        {items.map((item, index) => {\n          const warning = item.id === \"free\" && status === \"over\";\n          return (\n            <button\n              key={item.id}\n              type=\"button\"\n              className=\"grid min-w-0 cursor-pointer justify-items-start gap-0.5 rounded-button border-0 bg-transparent p-2 text-left text-inherit [-webkit-tap-highlight-color:transparent] transition-[background-color,opacity] duration-fast ease-standard hover:bg-hover aria-pressed:bg-hover data-[highlight=off]:opacity-45 motion-reduce:transition-none\"\n              data-highlight={active ? (active === item.id ? \"on\" : \"off\") : undefined}\n              aria-pressed={pinned === item.id}\n              aria-label={`${item.label}, ${format(item.value)}`}\n              tabIndex={index === roving ? 0 : -1}\n              onClick={() => {\n                setRoving(index);\n                setPinned((current) => (current === item.id ? null : item.id));\n              }}\n              onFocus={() => {\n                setRoving(index);\n                setFocused(item.id);\n              }}\n              onBlur={() => setFocused(null)}\n              onPointerEnter={(event) => {\n                if (event.pointerType === \"mouse\") setHovered(item.id);\n              }}\n              onPointerLeave={(event) => {\n                if (event.pointerType === \"mouse\") setHovered(null);\n              }}\n            >\n              <span className=\"flex min-w-0 max-w-full items-center gap-2 text-(length:--text-sm) leading-body text-ink-2\">\n                <span className=\"grid size-3 flex-none place-items-center\" aria-hidden=\"true\">\n                  <AnimatePresence mode=\"popLayout\" initial={false}>\n                    {warning ? (\n                      <motion.span\n                        key=\"alert\"\n                        className=\"grid place-items-center text-red [grid-area:1/1]\"\n                        variants={reduced ? fade : pop}\n                        initial=\"hidden\"\n                        animate=\"shown\"\n                        exit=\"gone\"\n                      >\n                        <TriangleAlert size={12} strokeWidth={2} />\n                      </motion.span>\n                    ) : (\n                      <motion.span\n                        key=\"swatch\"\n                        className={cn(\n                          \"size-2.5 rounded-[3px] [grid-area:1/1]\",\n                          item.id === \"free\"\n                            ? cn(trackFill, \"shadow-[inset_0_0_0_1px_var(--line-strong)]\")\n                            : segmentFill[index],\n                        )}\n                        data-index={item.id === \"free\" ? \"free\" : index}\n                        variants={reduced ? fade : pop}\n                        initial=\"hidden\"\n                        animate=\"shown\"\n                        exit=\"gone\"\n                      />\n                    )}\n                  </AnimatePresence>\n                </span>\n                <span className=\"min-w-0 flex-1\">\n                  <Swap text={item.label} reduced={reduced} />\n                </span>\n              </span>\n              <span className=\"flex items-baseline gap-1 pl-5 text-(length:--text-base) leading-body font-medium text-ink\">\n                <Ticker value={item.value} decimals={decimals} reduced={reduced} />\n                {unit && <span className=\"text-(length:--text-sm) font-normal text-ink-2\">{unit}</span>}\n              </span>\n            </button>\n          );\n        })}\n      </div>\n      <p className={srOnly} aria-live=\"polite\" aria-atomic=\"true\">\n        {announced.message}\n      </p>\n    </div>\n  );\n}\n\nexport default UsageMeter;\n",
      "type": "registry:component",
      "target": "@components/iris/usage-meter/usage-meter.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/usage-meter",
  "meta": {
    "kind": "component",
    "group": "Feedback",
    "export": "UsageMeter",
    "tags": [
      "meter",
      "usage",
      "storage"
    ],
    "docs": "https://irisui.dev/components/usage-meter"
  }
}
