{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"storage-meter","type":"registry:component","title":"Storage meter","description":"Point at any slice to read its size, then clear the cache and watch the total count down.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/storage-meter.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport {\n  AnimatePresence,\n  animate,\n  motion,\n  useMotionValue,\n  useTransform,\n} from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { usePreviewPlay } from \"@/lib/preview-play\";\nimport { cn } from \"@/lib/utils\";\n\nexport type StorageCategory = {\n  id: string;\n  name: string;\n  gb: number;\n  // Category colours are data, so raw values.\n  color: string;\n};\n\nconst EASE_OUT = [0.23, 1, 0.32, 1] as const;\nconst RESIZE = { type: \"spring\", duration: 0.6, bounce: 0 } as const;\n\nconst gb = (n: number) => `${n.toFixed(1)} GB`;\n\nexport function StorageMeter({\n  capacity,\n  categories,\n  cleanable,\n  focus: controlledFocus,\n  cleared: controlledCleared,\n  onClearedChange,\n  className,\n}: {\n  capacity: number;\n  categories: StorageCategory[];\n  // The category the clean-up action removes.\n  cleanable?: string;\n  // Drive the highlight and clean-up from outside (the card preview).\n  focus?: string | null;\n  cleared?: boolean;\n  onClearedChange?: (cleared: boolean) => void;\n  className?: string;\n}) {\n  const reduceMotion = useReducedMotion();\n  const [hover, setHover] = useState<string | null>(null);\n  const [clearedState, setClearedState] = useState(false);\n  const focus = controlledFocus !== undefined ? controlledFocus : hover;\n  const cleared = controlledCleared ?? clearedState;\n\n  const shown = categories.map((c) => ({\n    ...c,\n    gb: cleared && c.id === cleanable ? 0 : c.gb,\n  }));\n  const used = shown.reduce((t, c) => t + c.gb, 0);\n  const active = shown.find((c) => c.id === focus && c.gb > 0);\n  const cleanGb = categories.find((c) => c.id === cleanable)?.gb ?? 0;\n\n  // Each clear gets its own burst of pieces, counted as the state flips\n  // (adjusting state during render, so no effect is needed).\n  const [lastCleared, setLastCleared] = useState(cleared);\n  const [burst, setBurst] = useState(0);\n  if (cleared !== lastCleared) {\n    setLastCleared(cleared);\n    if (cleared) setBurst((b) => b + 1);\n  }\n  // Where the cleanable slice sits in the bar, as percentages, so the\n  // pieces start from it with no measuring.\n  const cleanIndex = categories.findIndex((c) => c.id === cleanable);\n  const cleanLeft =\n    (categories.slice(0, Math.max(0, cleanIndex)).reduce((t, c) => t + c.gb, 0) /\n      capacity) *\n    100;\n  const cleanWidth = (cleanGb / capacity) * 100;\n\n  // The used total counts to its new value instead of jumping. It's a\n  // motion value rendered as text, so counting never re-renders anything.\n  const usedValue = useMotionValue(used);\n  const usedLabel = useTransform(usedValue, gb);\n  useEffect(() => {\n    if (reduceMotion) return usedValue.set(used);\n    const a = animate(usedValue, used, { duration: 0.6, ease: EASE_OUT });\n    return () => a.stop();\n  }, [used, reduceMotion, usedValue]);\n\n  const setCleared = (next: boolean) => {\n    setClearedState(next);\n    onClearedChange?.(next);\n  };\n\n  const swap = reduceMotion ? { duration: 0 } : { duration: 0.2, ease: EASE_OUT };\n  const resize = reduceMotion ? { duration: 0 } : RESIZE;\n\n  return (\n    <div\n      className={cn(\n        \"w-[min(440px,100%)] rounded-2xl bg-background p-6 shadow-raised\",\n        className,\n      )}\n    >\n      {/* The headline is the readout: the total, or whatever you point at. */}\n      <div className=\"relative h-12\">\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          <motion.div\n            key={active?.id ?? \"total\"}\n            initial={{ opacity: 0, y: 4, filter: \"blur(4px)\" }}\n            animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n            exit={{ opacity: 0, y: -4, filter: \"blur(4px)\", transition: { duration: 0.12 } }}\n            transition={swap}\n          >\n            <p className=\"text-[12px] font-medium text-muted-foreground\">\n              {active ? active.name : \"Storage\"}\n            </p>\n            <p className=\"mt-1 text-[15px] text-muted-foreground\">\n              {active ? (\n                <span className=\"text-[22px] font-semibold tracking-tight text-foreground tabular-nums\">\n                  {gb(active.gb)}\n                </span>\n              ) : (\n                <>\n                  <motion.span className=\"text-[22px] font-semibold tracking-tight text-foreground tabular-nums\">\n                    {usedLabel}\n                  </motion.span>{\" \"}\n                  of {capacity} GB used\n                </>\n              )}\n            </p>\n          </motion.div>\n        </AnimatePresence>\n      </div>\n\n      <div className=\"relative mt-4\">\n        <div\n          aria-hidden\n          className=\"flex h-3 overflow-hidden rounded-full bg-muted\"\n          onPointerLeave={() => setHover(null)}\n        >\n          {shown.map((c) => {\n            const crushed = cleared && c.id === cleanable;\n            return (\n              // The slice's width carries its own 2px gap inside it, so a\n              // slice shrinking to nothing takes its gap along with it.\n              <motion.div\n                key={c.id}\n                initial={false}\n                animate={{ width: `${(c.gb / capacity) * 100}%` }}\n                // A crushed slice holds its width a beat, squashed, then\n                // the gap closes behind the pieces.\n                transition={\n                  crushed && !reduceMotion ? { ...RESIZE, delay: 0.3 } : resize\n                }\n                onPointerEnter={() => setHover(c.id)}\n                className=\"relative h-full shrink-0 overflow-hidden transition-opacity duration-150 ease-out\"\n                style={{ opacity: focus && focus !== c.id ? 0.3 : 1 }}\n              >\n                <motion.span\n                  className=\"absolute inset-y-0 left-0 right-[2px] origin-center\"\n                  initial={false}\n                  // Squashes under the press, then hands over to its shards.\n                  animate={\n                    crushed && !reduceMotion\n                      ? { scaleY: 0.45, scaleX: 1.04, opacity: 0 }\n                      : { scaleY: 1, scaleX: 1, opacity: 1 }\n                  }\n                  transition={\n                    crushed\n                      ? {\n                          duration: CRUSH,\n                          ease: [0.55, 0, 1, 0.45],\n                          // Gone the instant its shards take over.\n                          opacity: { delay: CRUSH, duration: 0 },\n                        }\n                      : { duration: 0.25, ease: EASE_OUT }\n                  }\n                  style={{ background: c.color }}\n                />\n              </motion.div>\n            );\n          })}\n        </div>\n        {cleared && burst > 0 && !reduceMotion && (\n          <Crumble\n            key={burst}\n            left={cleanLeft}\n            width={cleanWidth}\n            color={categories[cleanIndex]?.color ?? \"currentColor\"}\n          />\n        )}\n      </div>\n\n      {/* Holds its three rows even when a category goes, so clearing never\n          shrinks the card and moves the button out from under you. */}\n      <ul className=\"mt-5 grid min-h-[6.5rem] auto-rows-min grid-cols-2 gap-x-4 gap-y-1\">\n        <AnimatePresence initial={false}>\n          {shown\n            .filter((c) => c.gb > 0)\n            .map((c) => (\n              <motion.li\n                key={c.id}\n                layout=\"position\"\n                initial={{ opacity: 0 }}\n                animate={{ opacity: 1 }}\n                exit={{ opacity: 0, filter: \"blur(4px)\", transition: { duration: 0.15 } }}\n                transition={swap}\n              >\n                <button\n                  type=\"button\"\n                  onPointerEnter={() => setHover(c.id)}\n                  onPointerLeave={() => setHover(null)}\n                  onFocus={() => setHover(c.id)}\n                  onBlur={() => setHover(null)}\n                  aria-label={`${c.name}, ${gb(c.gb)}`}\n                  className={cn(\n                    \"flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-[13px] outline-hidden transition-[background-color,opacity] duration-150 ease-out focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-foreground\",\n                    focus === c.id && \"bg-muted\",\n                    focus && focus !== c.id && \"opacity-50\",\n                  )}\n                >\n                  <span\n                    aria-hidden\n                    className=\"size-2 shrink-0 rounded-full\"\n                    style={{ background: c.color }}\n                  />\n                  <span className=\"flex-1 text-foreground\">{c.name}</span>\n                  <span className=\"text-muted-foreground tabular-nums\">{gb(c.gb)}</span>\n                </button>\n              </motion.li>\n            ))}\n        </AnimatePresence>\n      </ul>\n\n      {cleanable && (\n        <div className=\"mt-4 flex items-center justify-between gap-3 border-t border-border pt-4\">\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            <motion.p\n              key={cleared ? \"done\" : \"offer\"}\n              initial={{ opacity: 0, filter: \"blur(4px)\" }}\n              animate={{ opacity: 1, filter: \"blur(0px)\" }}\n              exit={{ opacity: 0, filter: \"blur(4px)\", transition: { duration: 0.12 } }}\n              transition={swap}\n              className=\"text-[13px] text-muted-foreground\"\n            >\n              {cleared ? `Freed ${gb(cleanGb)}` : `${gb(cleanGb)} can be cleared`}\n            </motion.p>\n          </AnimatePresence>\n          <button\n            type=\"button\"\n            onClick={() => setCleared(!cleared)}\n            className=\"h-9 shrink-0 rounded-full bg-foreground px-4 text-[13px] font-medium text-background outline-hidden transition-[scale,opacity] duration-150 ease-out hover:opacity-90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-foreground active:scale-[0.96]\"\n          >\n            {cleared ? \"Undo\" : \"Clear cache\"}\n          </button>\n        </div>\n      )}\n      <span className=\"sr-only\" aria-live=\"polite\">\n        {cleared ? `Cache cleared, ${gb(used)} of ${capacity} GB used` : \"\"}\n      </span>\n    </div>\n  );\n}\n\n// The crushed slice shatters: its own area splits into shards that spray\n// out from the middle, arc, spin and fall under gravity, bounce once or\n// twice on the shelf below the bar and skid to a stop before fading, while\n// a puff of dust drifts down against air drag. Drawn on a canvas that\n// exists only for the second or so it runs.\nconst CRUSH = 0.12; // s the slice spends squashing before it breaks\nconst TOP = 48; // px of room above the bar for shards to hop into\nconst BAR = 12; // the bar's height\nconst SHELF = 18; // px below the bar where the pieces land\nconst GRAVITY = 900; // px/s²\n\ntype Shard = {\n  x: number;\n  y: number;\n  vx: number;\n  vy: number;\n  rot: number;\n  spin: number;\n  points: [number, number][];\n  shade: number;\n  bounces: number;\n  resting: boolean;\n  life: number;\n};\ntype Dust = { x: number; y: number; vx: number; vy: number; r: number; a: number; life: number };\n\nfunction Crumble({\n  left,\n  width,\n  color,\n}: {\n  left: number;\n  width: number;\n  color: string;\n}) {\n  const ref = useRef<HTMLCanvasElement>(null);\n\n  useEffect(() => {\n    const canvas = ref.current;\n    const ctx = canvas?.getContext(\"2d\");\n    if (!canvas || !ctx) return;\n    // Offsets, not client rects, so a scaled index card draws right too.\n    const W = canvas.offsetWidth;\n    const H = canvas.offsetHeight;\n    const dpr = window.devicePixelRatio || 1;\n    canvas.width = W * dpr;\n    canvas.height = H * dpr;\n    ctx.scale(dpr, dpr);\n\n    const rand = Math.random;\n    const x0 = (left / 100) * W;\n    const w = Math.max(6, (width / 100) * W - 2);\n    const squashed = BAR * 0.45;\n    const y0 = TOP + (BAR - squashed) / 2;\n    const cx = x0 + w / 2;\n    const floor = TOP + BAR + SHELF;\n\n    // The slice's own area, cut into a grid of uneven, jagged shards.\n    const cols = Math.max(3, Math.round(w / 2.6));\n    const rows = 3;\n    const shards: Shard[] = [];\n    for (let r = 0; r < rows; r++) {\n      for (let c = 0; c < cols; c++) {\n        const cw = w / cols;\n        const ch = squashed / rows;\n        const x = x0 + (c + 0.5) * cw;\n        const y = y0 + (r + 0.5) * ch;\n        const size = Math.max(cw, ch * 1.8) * (0.7 + rand() * 0.6);\n        const sides = 4 + Math.floor(rand() * 2);\n        const points: [number, number][] = Array.from({ length: sides }, (_, i) => {\n          const a = (i / sides) * Math.PI * 2 + rand() * 0.6;\n          const d = (size / 2) * (0.6 + rand() * 0.5);\n          return [Math.cos(a) * d, Math.sin(a) * d];\n        });\n        const out = (x - cx) / (w / 2); // -1 at the left edge, 1 at the right\n        shards.push({\n          x,\n          y,\n          vx: out * 70 + (rand() - 0.5) * 60,\n          vy: -(30 + rand() * 110) - (rows - r) * 12,\n          rot: rand() * Math.PI,\n          spin: (rand() - 0.5) * 16,\n          points,\n          shade: rand(),\n          bounces: 0,\n          resting: false,\n          life: 1,\n        });\n      }\n    }\n    const dust: Dust[] = Array.from({ length: 34 }, () => ({\n      x: cx + (rand() - 0.5) * w,\n      y: y0 + rand() * squashed,\n      vx: (rand() - 0.5) * 110,\n      vy: -(8 + rand() * 55),\n      r: 0.4 + rand() * 0.9,\n      a: 0.35 + rand() * 0.4,\n      life: 0.8 + rand() * 0.5,\n    }));\n\n    let raf = 0;\n    const start = performance.now();\n    let last = start;\n    const tick = (now: number) => {\n      const t = (now - start) / 1000;\n      const dt = Math.min(0.033, (now - last) / 1000);\n      last = now;\n      ctx.clearRect(0, 0, W, H);\n      if (t < CRUSH) {\n        raf = requestAnimationFrame(tick);\n        return;\n      }\n      let alive = false;\n\n      for (const s of shards) {\n        if (s.life <= 0) continue;\n        alive = true;\n        if (!s.resting) {\n          s.vy += GRAVITY * dt;\n          s.x += s.vx * dt;\n          s.y += s.vy * dt;\n          s.rot += s.spin * dt;\n          if (s.y >= floor) {\n            s.y = floor;\n            // Lands, bounces a little lower each time, then skids.\n            if (s.vy > 70 && s.bounces < 2) {\n              s.vy *= -0.3;\n              s.vx *= 0.55;\n              s.spin *= 0.5;\n              s.bounces++;\n            } else {\n              s.vy = 0;\n              s.resting = true;\n            }\n          }\n        } else {\n          s.vx *= 1 - 8 * dt;\n          s.x += s.vx * dt;\n          s.spin *= 1 - 8 * dt;\n          s.rot += s.spin * dt;\n        }\n        if (s.resting || t > 1) s.life -= dt * 2.4;\n\n        ctx.save();\n        ctx.translate(s.x, s.y);\n        ctx.rotate(s.rot);\n        ctx.beginPath();\n        s.points.forEach(([px, py], i) => (i ? ctx.lineTo(px, py) : ctx.moveTo(px, py)));\n        ctx.closePath();\n        ctx.globalAlpha = Math.max(0, Math.min(1, s.life));\n        ctx.fillStyle = color;\n        ctx.fill();\n        // Faces catch the light differently as they tumble.\n        const light = Math.sin(s.rot * 2 + s.shade * 6);\n        ctx.fillStyle = light > 0 ? \"rgba(255,255,255,1)\" : \"rgba(0,0,0,1)\";\n        ctx.globalAlpha *= Math.abs(light) * 0.2;\n        ctx.fill();\n        ctx.restore();\n      }\n\n      for (const d of dust) {\n        if (d.life <= 0) continue;\n        alive = true;\n        d.vx *= 1 - 3.5 * dt;\n        d.vy = (d.vy + 140 * dt) * (1 - 1.8 * dt);\n        d.x += d.vx * dt;\n        d.y += d.vy * dt;\n        d.life -= dt;\n        ctx.globalAlpha = Math.max(0, d.a * Math.min(1, d.life * 1.5));\n        ctx.fillStyle = color;\n        ctx.beginPath();\n        ctx.arc(d.x, d.y, d.r, 0, Math.PI * 2);\n        ctx.fill();\n      }\n\n      if (alive) raf = requestAnimationFrame(tick);\n      else ctx.clearRect(0, 0, W, H);\n    };\n    raf = requestAnimationFrame(tick);\n    return () => cancelAnimationFrame(raf);\n  }, [left, width, color]);\n\n  return (\n    <canvas\n      ref={ref}\n      aria-hidden\n      className=\"pointer-events-none absolute left-0 w-full\"\n      style={{ top: -TOP, height: TOP + BAR + SHELF + 16 }}\n    />\n  );\n}\n\nconst CATEGORIES: StorageCategory[] = [\n  { id: \"apps\", name: \"Apps\", gb: 14.2, color: \"oklch(0.62 0.15 255)\" },\n  { id: \"photos\", name: \"Photos\", gb: 11.6, color: \"oklch(0.76 0.14 70)\" },\n  { id: \"docs\", name: \"Documents\", gb: 6.3, color: \"oklch(0.68 0.12 160)\" },\n  { id: \"cache\", name: \"Cache\", gb: 2.4, color: \"oklch(0.66 0.16 15)\" },\n  {\n    id: \"system\",\n    name: \"System\",\n    gb: 4.1,\n    color: \"light-dark(oklch(0.72 0 0), oklch(0.5 0 0))\",\n  },\n];\n\nexport default function StorageMeterDemo() {\n  const play = usePreviewPlay();\n  const [demo, setDemo] = useState<{ focus: string | null; cleared: boolean }>({\n    focus: null,\n    cleared: false,\n  });\n\n  // The card's hover show: glance at photos and apps, clear the cache,\n  // then put it back.\n  useEffect(() => {\n    if (play !== true) return;\n    const timers: ReturnType<typeof setTimeout>[] = [];\n    const at = (ms: number, fn: () => void) => timers.push(setTimeout(fn, ms));\n    const run = () => {\n      at(300, () => setDemo({ focus: \"photos\", cleared: false }));\n      at(1300, () => setDemo({ focus: \"apps\", cleared: false }));\n      at(2300, () => setDemo({ focus: null, cleared: false }));\n      at(2800, () => setDemo({ focus: null, cleared: true }));\n      at(4800, () => setDemo({ focus: null, cleared: false }));\n      at(6000, run);\n    };\n    run();\n    return () => {\n      timers.forEach(clearTimeout);\n      setDemo({ focus: null, cleared: false });\n    };\n  }, [play]);\n\n  return (\n    <StorageMeter\n      capacity={64}\n      categories={CATEGORIES}\n      cleanable=\"cache\"\n      focus={play === true ? demo.focus : undefined}\n      cleared={play === true ? demo.cleared : undefined}\n    />\n  );\n}\n"},{"path":"hooks/use-reduced-motion.ts","type":"registry:hook","target":"hooks/use-reduced-motion.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// Motion's useReducedMotion reads the media query on the very first client\n// render, but the server can't know it, so every component that renders\n// differently under reduced motion broke hydration for those users. This\n// reports false during hydration, matching the server HTML, then the real\n// preference straight after, and follows it if it changes.\nconst query = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribe(onChange: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n"},{"path":"lib/preview-play.ts","type":"registry:lib","target":"lib/preview-play.ts","content":"\"use client\";\n\nimport { createContext, useContext } from \"react\";\n\n// Lets a demo know whether it's showing in an index card, and whether that\n// card is hovered or focused. Demos use it to act themselves out on hover:\n// a star button stars itself, a checkbox scribbles, and so on.\n//\n// null: not in a preview (the component's own page, or real use).\n// false: in a preview, at rest.\n// true: in a preview, being hovered or focused: play the show.\nexport const PreviewPlayContext = createContext<boolean | null>(null);\n\nexport function usePreviewPlay() {\n  return useContext(PreviewPlayContext);\n}\n"}],"cssVars":{"light":{"shadow-raised":"0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.06), 0 6px 16px -6px oklch(0 0 0 / 0.12)"},"dark":{"shadow-raised":"0 0 0 1px oklch(1 0 0 / 0.08), 0 1px 2px oklch(0 0 0 / 0.4), 0 6px 16px -6px oklch(0 0 0 / 0.6)"},"theme":{"shadow-raised":"var(--shadow-raised)"}},"categories":["data"],"docs":"From ui lab: https://lab.xevrion.dev/lab/storage-meter"}