{
  "$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://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/usage-meter/usage-meter.tsx",
      "type": "registry:component",
      "target": "@components/xzero/usage-meter/usage-meter.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore, type KeyboardEvent, type PointerEvent } from \"react\";\nimport { AnimatePresence, animate, cancelFrame, frame, motion, motionValue, useInView, useMotionValue, useReducedMotion, useTransform, type AnimationPlaybackControls, type MotionValue, type Variants } from \"motion/react\";\nimport { CircleAlert, TriangleAlert } from \"lucide-react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./usage-meter.module.css\";\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 = .001) => { const root = (2 * Math.PI) / (visualDuration * 1.2); return { type: \"spring\" as const, stiffness: root * root, damping: 2 * (1 - bounce) * root, restDelta, restSpeed: restDelta * 2 }; };\nconst settle = physical(spring.smooth);\nconst reveal = physical({ visualDuration: duration.considered + .1, bounce: 0 });\nconst turn = physical(spring.smooth);\nconst fit = physical(spring.morph, .01);\n/** A segment stops two pixels short of the next, so neighbours read apart without an outline. */\nconst GAP = 2;\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(subscribeNothing, () => true, () => false);\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 = () => { controls = start(); };\n  frame.update(run);\n  return () => { cancelFrame(run); controls?.stop(); };\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: `${.3 * direction}em`, scale: 1, filter: `blur(${blur.soft}px)` }),\n  shown: { opacity: 1, y: 0, scale: 1, filter: \"blur(0px)\", transition: { duration: duration.standard, ease: [...ease.enter] } },\n  gone: (direction: number) => ({ opacity: 0, y: `${-.3 * direction}em`, scale: 1, filter: `blur(${blur.subtle}px)`, transition: { duration: duration.instant, ease: [...ease.standard] } }),\n};\nconst pop: Variants = {\n  hidden: { opacity: 0, y: 0, scale: .6, filter: `blur(${blur.subtle + 1}px)` },\n  shown: { opacity: 1, y: 0, scale: 1, filter: \"blur(0px)\", transition: { ...spring.snappy, opacity: { duration: duration.fast, ease: [...ease.enter] }, filter: { duration: duration.fast, ease: [...ease.enter] } } },\n  gone: { opacity: 0, y: 0, scale: .6, filter: `blur(${blur.subtle + 1}px)`, transition: { duration: duration.instant, ease: [...ease.standard] } },\n};\nconst fade: Variants = { hidden: { opacity: 0, y: 0, scale: 1, filter: \"blur(0px)\" }, shown: { opacity: 1, y: 0, scale: 1, filter: \"blur(0px)\", transition: { duration: duration.fast } }, gone: { opacity: 0, y: 0, scale: 1, filter: \"blur(0px)\", transition: { duration: duration.instant } } };\n\nfunction Swap({ text, reduced, direction = 1 }: { text: string; reduced: boolean; direction?: number }) {\n  return <span className={styles.swap}><AnimatePresence mode=\"popLayout\" initial={false} custom={direction}>\n    <motion.span key={text} className={styles.swapText} custom={direction} variants={reduced ? fade : rise} initial=\"hidden\" animate=\"shown\" exit=\"gone\">{text}</motion.span>\n  </AnimatePresence></span>;\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) { format = new Intl.NumberFormat(\"en-US\", { minimumFractionDigits: decimals, maximumFractionDigits: decimals }); formats.set(decimals, format); }\n  const parts = format.formatToParts(value);\n  let place = parts.reduce((count, part) => count + (part.type === \"integer\" ? part.value.length : 0), 0), fraction = 0;\n  return parts.flatMap((part, index): Part[] => {\n    if (part.type === \"integer\") return [...part.value].map(char => ({ key: `i${--place}`, digit: Number(char) }));\n    if (part.type === \"fraction\") return [...part.value].map(char => ({ key: `f${fraction++}`, digit: Number(char) }));\n    return [{ key: part.type === \"group\" ? `g${place}` : part.type === \"decimal\" ? \"d\" : `${part.type}${index}`, text: part.value }];\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 => Math.abs(current) < .02 || Math.abs(current) >= 1 ? \"none\" : `blur(${(Math.abs(current) * blur.subtle).toFixed(2)}px)`);\n  return <motion.span className={styles.glyph} style={{ y, opacity, filter }}>{digit}</motion.span>;\n}\n\nconst column = { initial: { width: 0, opacity: 0 }, animate: { width: \"auto\", opacity: 1 }, exit: { width: 0, opacity: 0 } };\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) { position.jump(state.target); running.current = null; return; }\n    const target = state.target;\n    running.current = soon(() => animate(position, target, turn));\n  }, [digit, direction, position, reduced]);\n  return <motion.span className={styles.column} {...column} transition={reduced ? { duration: 0 } : fit}>\n    <span className={styles.sizer}>0</span>\n    {DIGITS.map(item => <Glyph key={item} position={position} digit={item} />)}\n  </motion.span>;\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 <span className={styles.ticker} aria-hidden=\"true\"><AnimatePresence initial={false}>\n    {partsFor(value, decimals).map(part => \"digit\" in part\n      ? <Column key={part.key} digit={part.digit} direction={trend.direction} reduced={reduced} />\n      : <motion.span key={part.key} className={styles.symbol} {...column} transition={reduced ? { duration: 0 } : fit}>{part.text}</motion.span>)}\n  </AnimatePresence></span>;\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 <motion.span className={styles.badge} data-status={status} style={{ width }}>\n    <span ref={inner} className={styles.badgeInner}>\n      <AnimatePresence mode=\"popLayout\" initial={false}>\n        {status !== \"ok\" && <motion.span key={status} className={styles.badgeIcon} variants={reduced ? fade : pop} initial=\"hidden\" animate=\"shown\" exit=\"gone\"><Icon size={14} strokeWidth={2} aria-hidden=\"true\" /></motion.span>}\n      </AnimatePresence>\n      <Swap text={text} reduced={reduced} />\n    </span>\n  </motion.span>;\n}\n\nfunction Segment({ index, amounts, span, width, highlight }: { index: number; amounts: MotionValue<number>[]; span: MotionValue<number>; width: MotionValue<number>; highlight: \"on\" | \"off\" | undefined }) {\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(() => { let start = 0; for (let at = 0; at < index; at++) start += amounts[at]!.get(); return (start / span.get()) * width.get(); });\n  const scaleX = useTransform(() => {\n    const w = width.get();\n    if (!w) return 0;\n    const size = (amounts[index]!.get() / span.get()) * w, rest = w - x.get() - size;\n    return Math.max(0, size - Math.min(GAP, size, Math.max(0, rest))) / w;\n  });\n  return <motion.span className={styles.segment} data-index={index} data-highlight={highlight} style={{ x, scaleX, originX: 0 }} />;\n}\n\nexport function UsageMeter({ label, segments, limit, unit = \"\", decimals = 1, freeLabel = \"Free\", overLabel = \"Over limit\", warnAt = .9 }: 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: .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)), 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 => { let value = store.get(segment.id); if (!value) { value = motionValue(0); store.set(segment.id, value); } return value; });\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 () => { stops.forEach(stop => stop()); fading?.stop(); };\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) { amount.jump(segment.value); return null; }\n      return soon(() => animate(amount, segment.value, first ? { ...reveal, delay: index * .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) { shownLimit.jump(limit); return; }\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 = [...segments.map(segment => ({ id: segment.id, label: segment.label, value: segment.value })), { id: \"free\", label: status === \"over\" ? overLabel : freeLabel, value: status === \"over\" ? over : free }];\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) { end += segment.value; if (at <= end) return segment.id; }\n    return \"free\";\n  };\n  const onTrackMove = (event: PointerEvent<HTMLDivElement>) => { if (event.pointerType === \"mouse\") setHovered(idAt(event.clientX)); };\n  const onTrackTap = (event: PointerEvent<HTMLDivElement>) => { if (event.pointerType !== \"mouse\") { const id = idAt(event.clientX); setPinned(current => current === id ? null : id); } };\n  const onLegendKey = (event: KeyboardEvent<HTMLDivElement>) => {\n    if (event.key === \"Escape\") { if (pinned || focused) { event.preventDefault(); setPinned(null); } return; }\n    const next = ({ ArrowRight: roving + 1, ArrowDown: roving + 1, ArrowLeft: roving - 1, ArrowUp: roving - 1, Home: 0, End: items.length - 1 } as Record<string, number>)[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 ? `${unit} of ${limitText} used` : activeItem.id === \"free\" ? `${unit} ${status === \"over\" ? \"over the limit\" : \"free\"}` : `${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) setAnnounced({ notice, message: status === \"over\" ? `Over the plan limit by ${format(over)}.` : status === \"near\" ? `Almost full. ${format(free)} free.` : `Under the limit. ${format(free)} free.` });\n\n  return <div ref={root} className={styles.meter} role=\"group\" aria-labelledby={titleId} data-status={status}>\n    <div className={styles.top}>\n      <span id={titleId} className={styles.title}>{label}</span>\n      <StatusBadge status={status} text={badge} reduced={reduced} />\n    </div>\n    <p className={styles.total}>\n      <span className={styles.srOnly}>{format(total)} of {limitText} used</span>\n      <span className={styles.amount} aria-hidden=\"true\"><Ticker value={headline} decimals={decimals} reduced={reduced} /></span>\n      <span className={styles.caption} aria-hidden=\"true\"><Swap text={caption} reduced={reduced} /></span>\n    </p>\n    <div className={styles.bar}>\n      <div ref={trackRef} className={styles.track} role=\"img\" aria-label={summary} data-active={active ?? undefined} onPointerMove={onTrackMove} onPointerLeave={() => setHovered(null)} onPointerUp={onTrackTap}>\n        {segments.map((segment, index) => <Segment key={segment.id} index={index} amounts={amounts} span={span} width={width} highlight={active ? active === segment.id ? \"on\" : \"off\" : undefined} />)}\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 className={styles.overflow} data-highlight={active ? active === \"free\" ? \"on\" : \"off\" : undefined} style={{ clipPath: overClip, opacity: markerOpacity }} aria-hidden=\"true\" focusable=\"false\">\n          <defs><pattern id={hatchId} width={6} height={6} patternUnits=\"userSpaceOnUse\" patternTransform=\"rotate(45)\"><rect width={2.5} height={6} className={styles.hatch} /></pattern></defs>\n          <rect className={styles.overTint} width=\"100%\" height=\"100%\" />\n          <rect width=\"100%\" height=\"100%\" fill={`url(#${hatchId})`} />\n        </motion.svg>\n      </div>\n      <motion.span className={styles.limit} style={{ x: limitX, opacity: markerOpacity }} aria-hidden=\"true\" />\n    </div>\n    <div ref={legend} className={styles.legend} role=\"group\" aria-label={`${label} by category`} onKeyDown={onLegendKey}>\n      {items.map((item, index) => {\n        const warning = item.id === \"free\" && status === \"over\";\n        return <button key={item.id} type=\"button\" className={styles.item} data-highlight={active ? active === item.id ? \"on\" : \"off\" : undefined} aria-pressed={pinned === item.id} aria-label={`${item.label}, ${format(item.value)}`} tabIndex={index === roving ? 0 : -1}\n          onClick={() => { setRoving(index); setPinned(current => current === item.id ? null : item.id); }}\n          onFocus={() => { setRoving(index); setFocused(item.id); }} onBlur={() => setFocused(null)}\n          onPointerEnter={event => { if (event.pointerType === \"mouse\") setHovered(item.id); }} onPointerLeave={event => { if (event.pointerType === \"mouse\") setHovered(null); }}>\n          <span className={styles.itemHead}>\n            <span className={styles.key} aria-hidden=\"true\"><AnimatePresence mode=\"popLayout\" initial={false}>\n              {warning\n                ? <motion.span key=\"alert\" className={styles.keyIcon} variants={reduced ? fade : pop} initial=\"hidden\" animate=\"shown\" exit=\"gone\"><TriangleAlert size={12} strokeWidth={2} /></motion.span>\n                : <motion.span key=\"swatch\" className={styles.swatch} data-index={item.id === \"free\" ? \"free\" : index} variants={reduced ? fade : pop} initial=\"hidden\" animate=\"shown\" exit=\"gone\" />}\n            </AnimatePresence></span>\n            <span className={styles.itemLabel}><Swap text={item.label} reduced={reduced} /></span>\n          </span>\n          <span className={styles.itemValue}><Ticker value={item.value} decimals={decimals} reduced={reduced} />{unit && <span className={styles.itemUnit}>{unit}</span>}</span>\n        </button>;\n      })}\n    </div>\n    <p className={styles.srOnly} aria-live=\"polite\" aria-atomic=\"true\">{announced.message}</p>\n  </div>;\n}\n\nexport default UsageMeter;\n"
    },
    {
      "path": "registry/components/usage-meter/usage-meter.module.css",
      "type": "registry:component",
      "target": "@components/xzero/usage-meter/usage-meter.module.css",
      "content": "/* Categories are steps of the accent, strongest first, so the meter reads as one allowance rather than a rainbow. Free space is the track itself. */\n.meter { --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)); display: grid; min-width: 0; gap: var(--space-4); color: var(--foreground); }\n.top { display: flex; min-width: 0; min-height: 28px; align-items: center; justify-content: space-between; gap: var(--space-3); }\n/* A long label wraps beside the status mark instead of hiding its end. */\n.title { min-width: 0; color: var(--foreground); font-size: var(--text-base); font-weight: 500; letter-spacing: var(--tracking-body); line-height: var(--leading-body); text-wrap: balance; }\n\n/* The status mark hugs its words: its width springs to each message and grows from the right edge, so the text never slides. */\n.badge { display: inline-flex; flex: none; height: 28px; justify-content: flex-end; overflow: hidden; border-radius: var(--radius-pill); background: var(--surface-muted); color: var(--text-secondary); font-size: var(--text-xs); font-weight: 500; line-height: 1; transition: background-color var(--duration-standard) var(--ease-standard), color var(--duration-standard) var(--ease-standard); }\n.badgeInner { display: inline-flex; flex: none; align-items: center; gap: 6px; padding: 0 var(--space-3); white-space: nowrap; }\n.badgeIcon { display: grid; place-items: center; }\n.badge[data-status=\"near\"] { background: color-mix(in oklab, var(--warning) 14%, var(--surface)); color: var(--foreground); }\n.badge[data-status=\"near\"] .badgeIcon { color: var(--warning); }\n.badge[data-status=\"over\"] { background: color-mix(in oklab, var(--danger) 12%, var(--surface)); color: var(--foreground); }\n.badge[data-status=\"over\"] .badgeIcon { color: var(--danger); }\n\n.total { display: flex; min-width: 0; align-items: baseline; gap: var(--space-2); margin: 0; }\n.amount { flex: none; font-family: var(--font-display); font-size: var(--text-3xl); font-weight: 500; letter-spacing: var(--tracking-display); line-height: var(--leading-display); }\n.caption { min-width: 0; flex: 1; color: var(--text-secondary); font-size: var(--text-sm); }\n.swap { position: relative; display: block; min-width: 0; }\n.swapText { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n\n/* The bar leaves room above and below the track for the limit marker. */\n.bar { position: relative; padding-block: 5px; }\n.track { position: relative; height: 14px; overflow: hidden; border-radius: var(--radius-pill); background: color-mix(in oklab, var(--foreground) 7%, var(--surface)); isolation: isolate; -webkit-tap-highlight-color: transparent; }\n.segment { position: absolute; inset: 0; transition: opacity var(--duration-fast) var(--ease-standard); }\n.segment[data-index=\"0\"], .swatch[data-index=\"0\"] { background: var(--segment-0); }\n.segment[data-index=\"1\"], .swatch[data-index=\"1\"] { background: var(--segment-1); }\n.segment[data-index=\"2\"], .swatch[data-index=\"2\"] { background: var(--segment-2); }\n.segment[data-index=\"3\"], .swatch[data-index=\"3\"] { background: var(--segment-3); }\n.segment[data-highlight=\"off\"] { opacity: .28; }\n/* Past the limit the used colours stay, struck through by a hatch; highlighting the overage floods it with the danger colour. */\n.overflow { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }\n.hatch { fill: var(--surface); }\n.overTint { fill: var(--danger); opacity: 0; transition: opacity var(--duration-fast) var(--ease-standard); }\n.overflow[data-highlight=\"on\"] .overTint { opacity: 1; }\n.limit { position: absolute; top: 0; bottom: 0; left: -1px; width: 2px; border-radius: 1px; background: var(--foreground); pointer-events: none; }\n\n/* The legend doubles as the keyboard path: arrow keys move between categories, focus previews one, a press pins it. */\n.legend { display: grid; grid-template-columns: repeat(auto-fit, minmax(108px, 1fr)); gap: 2px; margin: 0 calc(var(--space-2) * -1); }\n.item { display: grid; min-width: 0; justify-items: start; gap: 2px; padding: var(--space-2); border: 0; border-radius: calc(var(--radius-control) - 6px); background: transparent; color: inherit; font: inherit; text-align: left; cursor: pointer; -webkit-tap-highlight-color: transparent; transition: background-color var(--duration-fast) var(--ease-standard), opacity var(--duration-fast) var(--ease-standard); }\n@media (hover: hover) and (pointer: fine) { .item:hover { background: var(--surface-muted); } }\n.item[aria-pressed=\"true\"] { background: var(--surface-muted); }\n.item[data-highlight=\"off\"] { opacity: .45; }\n.itemHead { display: flex; min-width: 0; max-width: 100%; align-items: center; gap: var(--space-2); color: var(--text-secondary); font-size: var(--text-sm); line-height: var(--leading-body); }\n.itemLabel { min-width: 0; flex: 1; }\n.key { display: grid; width: 12px; height: 12px; flex: none; place-items: center; }\n.swatch { grid-area: 1 / 1; width: 10px; height: 10px; border-radius: 3px; }\n.swatch[data-index=\"free\"] { background: color-mix(in oklab, var(--foreground) 7%, var(--surface)); box-shadow: inset 0 0 0 1px var(--border-strong); }\n.keyIcon { grid-area: 1 / 1; display: grid; place-items: center; color: var(--danger); }\n.itemValue { display: flex; align-items: baseline; gap: 4px; padding-left: 20px; color: var(--foreground); font-size: var(--text-base); font-weight: 500; line-height: var(--leading-body); }\n.itemUnit { color: var(--text-secondary); font-size: var(--text-sm); font-weight: 400; }\n\n/* A rolling number: each digit is a clipped wheel that feathers at its edges, and new columns open from zero width. */\n.ticker { position: relative; display: inline-flex; align-items: flex-start; white-space: nowrap; font-variant-numeric: tabular-nums; user-select: none; }\n.column { --feather: .16em; position: relative; display: inline-block; overflow: hidden; margin-block: calc(var(--feather) * -1); padding-block: var(--feather); mask-image: linear-gradient(to bottom, transparent, #000 calc(var(--feather) * 1.5), #000 calc(100% - var(--feather) * 1.5), transparent); }\n.sizer { visibility: hidden; }\n.glyph { position: absolute; inset: var(--feather) 0; text-align: center; }\n.symbol { display: inline-block; overflow-x: clip; }\n\n.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n@media (prefers-reduced-motion: reduce) { .badge, .segment, .item, .overTint { transition: none; } }\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/usage-meter",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "meter",
      "usage",
      "storage"
    ],
    "docs": "https://ui.xzero.co.uk/components/usage-meter",
    "markdown": "https://ui.xzero.co.uk/components/usage-meter/markdown"
  }
}