{"$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://uiarc.dev/r/arc-foundation.json"],"files":[{"path":"registry/components/usage-meter/usage-meter.tsx","type":"registry:component","target":"@components/arc/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 \n {text}\n ;\n}\n\ntype Part = { key: string; digit: number } | { key: string; text: string };\nconst formats = new Map();\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; 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 {digit};\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 \n 0\n {DIGITS.map(item => )}\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 {partsFor(value, decimals).map(part => \"digit\" in part\n ? \n : {part.text})}\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(null);\n const width = useMotionValue(\"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 \n \n {status !== \"ok\" && }\n \n \n \n ;\n}\n\nfunction Segment({ index, amounts, span, width, highlight }: { index: number; amounts: MotionValue[]; span: MotionValue; width: MotionValue; 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 ;\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(null);\n const trackRef = useRef(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>());\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(null);\n const [focused, setFocused] = useState(null);\n const [pinned, setPinned] = useState(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(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) => { if (event.pointerType === \"mouse\") setHovered(idAt(event.clientX)); };\n const onTrackTap = (event: PointerEvent) => { if (event.pointerType !== \"mouse\") { const id = idAt(event.clientX); setPinned(current => current === id ? null : id); } };\n const onLegendKey = (event: KeyboardEvent) => {\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)[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(\"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
\n
\n {label}\n \n
\n
\n {format(total)} of {limitText} used\n \n \n
\n
\n
setHovered(null)} onPointerUp={onTrackTap}>\n {segments.map((segment, index) => )}\n {/* Past the limit the bar is struck through: the overflow keeps its colours, hatched so it reads as over without colour. */}\n \n \n \n \n \n