{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"paper-shredder","type":"registry:component","title":"Paper shredder","description":"Drag a file into the shredder and it comes out in strips.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/paper-shredder.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useId, useLayoutEffect, useRef, useState } from \"react\";\nimport {\n  animate,\n  motion,\n  useMotionValue,\n  useTransform,\n  type AnimationPlaybackControls,\n  type MotionValue,\n} from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\nexport type ShredderFile = { id: string; name: string; meta: string };\n\ntype Fall = { dy: number; rot: number; delay: number };\ntype Flight = {\n  key: number;\n  file: ShredderFile;\n  index: number;\n  top: number;\n  x: number;\n  velocity: number;\n  mode: \"shred\" | \"restore\";\n  falls: Fall[];\n};\n\nconst ROW_H = 52;\nconst ROW_GAP = 6;\nconst ROWS = 4;\nconst LIST_H = ROWS * ROW_H + (ROWS - 1) * ROW_GAP;\nconst HEAD_GAP = 20;\nconst HEAD_H = 48;\n// The paper disappears in the middle of the 4px mouth groove, 6px into the\n// head, so it reads as going into the slot rather than behind the body.\nconst SLOT_Y = LIST_H + HEAD_GAP + 8;\nconst BIN_TOP = LIST_H + HEAD_GAP + HEAD_H;\nconst BIN_H = 88;\nconst STRIPS = 10;\n// A motor feeds paper at a constant speed, so the feed is linear on\n// purpose: 52px in 360ms.\nconst FEED = 0.36;\n// Each strip falls for 420ms, longer than UI motion because it is gravity,\n// and the scatter of start delays (up to 90ms) keeps them from moving as one.\nconst FALL = 0.42;\nconst MAX_DELAY = 0.09;\n// Drags within this distance of the slot, or thrown down faster than this\n// (px/s), get pulled in on release.\nconst GRAB_DISTANCE = 64;\nconst GRAB_VELOCITY = 600;\nconst EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nconst EASE_OUT = [0.23, 1, 0.32, 1] as const;\nconst RETURN = { type: \"spring\", stiffness: 420, damping: 34 } as const;\n// How long the undo toast waits before leaving on its own.\nconst TOAST_MS = 6000;\n\nfunction rollFalls(): Fall[] {\n  return Array.from({ length: STRIPS }, () => ({\n    dy: 32 + Math.random() * 28,\n    rot: (Math.random() - 0.5) * 14,\n    delay: Math.random() * MAX_DELAY,\n  }));\n}\n\nexport function PaperShredder({\n  files: initialFiles,\n  onShred,\n  onRestore,\n  className,\n}: {\n  files: ShredderFile[];\n  onShred?: (file: ShredderFile) => void;\n  onRestore?: (file: ShredderFile) => void;\n  className?: string;\n}) {\n  const reduceMotion = useReducedMotion();\n  const hintId = useId();\n  const [files, setFiles] = useState(initialFiles);\n  const [flights, setFlights] = useState<Flight[]>([]);\n  const [trash, setTrash] = useState<Flight[]>([]);\n  const [running, setRunning] = useState(0);\n  const [toast, setToast] = useState(false);\n  const nextKey = useRef(0);\n  const rows = useRef(new Map<string, HTMLLIElement>());\n  // Where each restored file's flight ended, so its row can glide home\n  // from there instead of appearing in place.\n  const arrivals = useRef(new Map<string, number>());\n  const toastTimer = useRef<ReturnType<typeof setTimeout>>(undefined);\n  // The last undo hides the toast and takes its button with it, so focus\n  // goes to the restored row instead of being dropped.\n  const refocus = useRef<string>(undefined);\n\n  useEffect(() => () => clearTimeout(toastTimer.current), []);\n\n  const showToast = () => {\n    setToast(true);\n    clearTimeout(toastTimer.current);\n    toastTimer.current = setTimeout(() => setToast(false), TOAST_MS);\n  };\n\n  const focusRow = (id: string | undefined) =>\n    requestAnimationFrame(() => {\n      if (id) rows.current.get(id)?.focus();\n    });\n\n  const shred = (id: string, top: number, x = 0, velocity = 0) => {\n    const index = files.findIndex((f) => f.id === id);\n    if (index === -1) return;\n    const file = files[index];\n    const flight: Flight = {\n      key: nextKey.current++,\n      file,\n      index,\n      top,\n      x,\n      velocity,\n      mode: \"shred\",\n      falls: rollFalls(),\n    };\n    setFiles((list) => list.filter((f) => f.id !== id));\n    onShred?.(file);\n    if (reduceMotion) {\n      setTrash((t) => [...t, flight]);\n      showToast();\n    } else {\n      setFlights((f) => [...f, flight]);\n    }\n    return files[index + 1]?.id ?? files[index - 1]?.id;\n  };\n\n  const land = (id: string, top: number, index: number, file: ShredderFile) => {\n    arrivals.current.set(id, top);\n    setFiles((list) => {\n      const next = [...list];\n      next.splice(Math.min(index, next.length), 0, file);\n      return next;\n    });\n    onRestore?.(file);\n    if (refocus.current === id) {\n      refocus.current = undefined;\n      focusRow(id);\n    }\n  };\n\n  const undo = () => {\n    const last = trash.at(-1);\n    if (!last) return;\n    setTrash((t) => t.slice(0, -1));\n    if (trash.length === 1) {\n      setToast(false);\n      refocus.current = last.file.id;\n    } else showToast();\n    if (reduceMotion) {\n      land(last.file.id, 0, last.index, last.file);\n      arrivals.current.delete(last.file.id);\n      return;\n    }\n    // Still falling: reverse it from wherever it is. Already gone: bring\n    // it back from the bin.\n    setFlights((f) =>\n      f.some((x) => x.key === last.key)\n        ? f.map((x) => (x.key === last.key ? { ...x, mode: \"restore\" } : x))\n        : [...f, { ...last, mode: \"restore\" }],\n    );\n  };\n\n  const latest = trash.at(-1);\n  // Holds the last name while the toast fades out, so it never empties\n  // mid-exit.\n  const [toastName, setToastName] = useState(\"\");\n  if (latest && latest.file.name !== toastName) setToastName(latest.file.name);\n\n  return (\n    <div\n      className={cn(\n        \"w-[min(440px,100%)] rounded-[20px] bg-muted p-4 shadow-raised\",\n        className,\n      )}\n    >\n      <div className=\"flex items-baseline justify-between px-3\">\n        <h2 className=\"text-base font-medium text-foreground\">Documents</h2>\n        <span className=\"text-xs text-muted-foreground tabular-nums\">\n          {files.length} {files.length === 1 ? \"file\" : \"files\"}\n        </span>\n      </div>\n\n      <div className=\"relative mt-3\" style={{ height: BIN_TOP + BIN_H }}>\n        <ul\n          aria-label=\"Documents\"\n          aria-describedby={hintId}\n          className=\"relative mx-3 flex flex-col\"\n          style={{ height: LIST_H, gap: ROW_GAP }}\n        >\n          {files.map((file) => (\n            <Row\n              key={file.id}\n              file={file}\n              arrivals={arrivals}\n              reduceMotion={!!reduceMotion}\n              register={(el) => {\n                if (el) rows.current.set(file.id, el);\n                else rows.current.delete(file.id);\n              }}\n              onShred={(top, x, velocity, fromKeyboard) => {\n                const next = shred(file.id, top, x, velocity);\n                if (fromKeyboard) focusRow(next);\n              }}\n              onMove={(delta) => {\n                const i = files.findIndex((f) => f.id === file.id);\n                focusRow(files[i + delta]?.id);\n              }}\n            />\n          ))}\n          {files.length === 0 && flights.length === 0 && (\n            <li className=\"flex h-full items-center justify-center text-sm text-muted-foreground\">\n              Nothing left to shred.\n            </li>\n          )}\n        </ul>\n        <p id={hintId} className=\"sr-only\">\n          Press Delete to shred the focused file, or drag it into the shredder.\n        </p>\n\n        <Head running={running > 0 && !reduceMotion} />\n        <BinBack count={trash.length} />\n\n        {flights.map((flight) => (\n          <FlightView\n            key={flight.key}\n            flight={flight}\n            onFeeding={(on) => setRunning((n) => n + (on ? 1 : -1))}\n            onShredded={() => {\n              setTrash((t) =>\n                t.some((x) => x.key === flight.key) ? t : [...t, flight],\n              );\n              showToast();\n            }}\n            onGone={() =>\n              setFlights((f) => f.filter((x) => x.key !== flight.key))\n            }\n            onLanded={(top) => {\n              setFlights((f) => f.filter((x) => x.key !== flight.key));\n              land(flight.file.id, top, flight.index, flight.file);\n            }}\n          />\n        ))}\n        <BinFront />\n      </div>\n\n      <div className=\"mt-2 flex h-10 items-center justify-center\">\n        <div\n          inert={!toast}\n          className={cn(\n            \"flex h-10 items-center gap-3 rounded-full bg-foreground pr-1 pl-4 text-sm text-background\",\n            \"transition-[opacity,filter,translate] ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-[opacity]\",\n            toast\n              ? \"translate-y-0 opacity-100 filter-none duration-300\"\n              : \"translate-y-1 opacity-0 blur-[4px] duration-150 motion-reduce:translate-y-0 motion-reduce:filter-none\",\n          )}\n        >\n          <span className=\"max-w-[220px] truncate\">\n            Shredded <span className=\"font-medium\">{toastName}</span>\n          </span>\n          <button\n            type=\"button\"\n            onClick={undo}\n            className=\"h-8 touch-manipulation rounded-full bg-background/15 px-3 text-sm font-medium text-background outline-hidden transition-[scale,background-color] duration-150 ease-out select-none hover:bg-background/25 focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-foreground active:scale-[0.96]\"\n          >\n            Undo\n          </button>\n        </div>\n      </div>\n      <span className=\"sr-only\" aria-live=\"polite\">\n        {toast && toastName ? `Shredded ${toastName}` : \"\"}\n      </span>\n    </div>\n  );\n}\n\nfunction Row({\n  file,\n  arrivals,\n  reduceMotion,\n  register,\n  onShred,\n  onMove,\n}: {\n  file: ShredderFile;\n  arrivals: React.RefObject<Map<string, number>>;\n  reduceMotion: boolean;\n  register: (el: HTMLLIElement | null) => void;\n  onShred: (\n    top: number,\n    x: number,\n    velocity: number,\n    fromKeyboard: boolean,\n  ) => void;\n  onMove: (delta: 1 | -1) => void;\n}) {\n  const ref = useRef<HTMLLIElement>(null);\n  const x = useMotionValue(0);\n  const y = useMotionValue(0);\n  const [lifted, setLifted] = useState(false);\n  const drag = useRef<{\n    id: number;\n    startX: number;\n    startY: number;\n    active: boolean;\n    lastY: number;\n    lastT: number;\n    velocity: number;\n  } | null>(null);\n  const spring = useRef<AnimationPlaybackControls>(undefined);\n\n  // A restored file glides from where its flight stopped into its slot.\n  useLayoutEffect(() => {\n    const from = arrivals.current.get(file.id);\n    const el = ref.current;\n    if (from === undefined || !el) return;\n    arrivals.current.delete(file.id);\n    y.jump(from - el.offsetTop);\n    spring.current = animate(y, 0, RETURN);\n  }, [arrivals, file.id, y]);\n\n  useEffect(() => () => spring.current?.stop(), []);\n\n  const top = () => (ref.current?.offsetTop ?? 0) + y.get();\n\n  const end = (commit: boolean) => {\n    const d = drag.current;\n    drag.current = null;\n    setLifted(false);\n    if (!d?.active) return;\n    if (commit) {\n      onShred(top(), x.get(), d.velocity, false);\n    } else {\n      spring.current = animate(y, 0, { ...RETURN, velocity: d.velocity });\n      animate(x, 0, RETURN);\n    }\n  };\n\n  return (\n    <motion.li\n      ref={(el) => {\n        ref.current = el;\n        register(el);\n      }}\n      layout={reduceMotion ? false : \"position\"}\n      transition={{ layout: { duration: 0.3, ease: EASE_OUT } }}\n      tabIndex={0}\n      aria-label={`${file.name}, ${file.meta}`}\n      style={{ x, y, height: ROW_H }}\n      className={cn(\n        \"relative shrink-0 cursor-grab touch-none rounded-[12px] outline-hidden select-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-foreground\",\n        lifted && \"z-20 cursor-grabbing\",\n      )}\n      onKeyDown={(e) => {\n        if (e.target !== e.currentTarget) return;\n        if (e.key === \"Delete\" || e.key === \"Backspace\") {\n          e.preventDefault();\n          onShred(top(), 0, 0, true);\n        }\n        if (e.key === \"ArrowDown\" || e.key === \"ArrowUp\") {\n          e.preventDefault();\n          onMove(e.key === \"ArrowDown\" ? 1 : -1);\n        }\n      }}\n      onPointerDown={(e) => {\n        if (e.button !== 0 || drag.current) return;\n        if ((e.target as HTMLElement).closest(\"button\")) return;\n        spring.current?.stop();\n        drag.current = {\n          id: e.pointerId,\n          startX: e.clientX - x.get(),\n          startY: e.clientY - y.get(),\n          active: false,\n          lastY: e.clientY,\n          lastT: e.timeStamp,\n          velocity: 0,\n        };\n        e.currentTarget.setPointerCapture(e.pointerId);\n      }}\n      onPointerMove={(e) => {\n        const d = drag.current;\n        if (!d || d.id !== e.pointerId) return;\n        const dx = e.clientX - d.startX;\n        const dy = e.clientY - d.startY;\n        // 4px of slop, so a press to focus never nudges the row.\n        if (!d.active && Math.hypot(dx, dy) < 4) return;\n        if (!d.active) {\n          d.active = true;\n          setLifted(true);\n        }\n        const dt = Math.max(e.timeStamp - d.lastT, 1);\n        // Smoothed so one jittery sample can't decide a throw.\n        d.velocity = d.velocity * 0.6 + ((e.clientY - d.lastY) / dt) * 400;\n        d.lastY = e.clientY;\n        d.lastT = e.timeStamp;\n        // Sideways drags give only 15%: the slot only takes paper straight.\n        x.set(dx * 0.15);\n        y.set(dy);\n        const bottom = top() + ROW_H;\n        // Touching the slot, the shredder takes it from your hand.\n        if (bottom >= SLOT_Y - 2) end(true);\n      }}\n      onPointerUp={(e) => {\n        const d = drag.current;\n        if (!d || d.id !== e.pointerId) return;\n        const bottom = top() + ROW_H;\n        end(bottom > SLOT_Y - GRAB_DISTANCE || d.velocity > GRAB_VELOCITY);\n      }}\n      onPointerCancel={(e) => {\n        if (drag.current?.id === e.pointerId) end(false);\n      }}\n    >\n      <FileFace\n        file={file}\n        lifted={lifted}\n        action={\n          <button\n            type=\"button\"\n            aria-label={`Shred ${file.name}`}\n            onClick={() => onShred(top(), x.get(), 0, false)}\n            className=\"flex size-9 shrink-0 touch-manipulation items-center justify-center rounded-full text-muted-foreground outline-hidden transition-[scale,color,background-color] duration-150 ease-out hover:bg-muted hover:text-foreground focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-foreground active:scale-[0.96]\"\n          >\n            <TrashIcon />\n          </button>\n        }\n      />\n    </motion.li>\n  );\n}\n\nfunction FileFace({\n  file,\n  lifted = false,\n  action,\n}: {\n  file: ShredderFile;\n  lifted?: boolean;\n  action?: React.ReactNode;\n}) {\n  return (\n    <div\n      className={cn(\n        \"flex h-full w-full items-center gap-3 rounded-[12px] bg-background pr-2 pl-3 shadow-raised transition-[box-shadow] duration-150 ease-out\",\n        lifted &&\n          \"shadow-[var(--shadow-raised),0_12px_24px_-8px_oklch(0_0_0/0.18)]\",\n      )}\n    >\n      <svg\n        aria-hidden\n        viewBox=\"0 0 20 20\"\n        className=\"size-5 shrink-0 text-muted-foreground\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeWidth={1.5}\n        strokeLinejoin=\"round\"\n      >\n        <path d=\"M5 2.75h6.5L15.25 6.5v10a.75.75 0 0 1-.75.75h-9.5a.75.75 0 0 1-.75-.75V3.5a.75.75 0 0 1 .75-.75Z\" />\n        <path d=\"M11.25 2.75V6.75h4\" />\n      </svg>\n      <div className=\"min-w-0 flex-1\">\n        <p className=\"truncate text-sm font-medium text-foreground\">\n          {file.name}\n        </p>\n        <p className=\"truncate text-xs text-muted-foreground\">{file.meta}</p>\n      </div>\n      {action ?? (\n        <span className=\"flex size-9 shrink-0 items-center justify-center text-muted-foreground\">\n          <TrashIcon />\n        </span>\n      )}\n    </div>\n  );\n}\n\n// One flight draws the paper above the slot and its strips below the\n// head from the same motion values, so both halves always agree.\nfunction FlightView({\n  flight,\n  onFeeding,\n  onShredded,\n  onGone,\n  onLanded,\n}: {\n  flight: Flight;\n  onFeeding: (on: boolean) => void;\n  onShredded: () => void;\n  onGone: () => void;\n  onLanded: (top: number) => void;\n}) {\n  const shredding = flight.mode === \"shred\";\n  // A flight created for an undo starts fully shredded, in the bin.\n  const y = useMotionValue(shredding ? flight.top : SLOT_Y);\n  const x = useMotionValue(shredding ? flight.x : 0);\n  const t = useMotionValue(shredding ? 0 : 1);\n  const depth = useTransform(y, (v) =>\n    Math.min(Math.max(v + ROW_H - SLOT_Y, 0), ROW_H),\n  );\n  const callbacks = useRef({ onFeeding, onShredded, onGone, onLanded });\n  useLayoutEffect(() => {\n    callbacks.current = { onFeeding, onShredded, onGone, onLanded };\n  });\n\n  useEffect(() => {\n    let cancelled = false;\n    let current: AnimationPlaybackControls | undefined;\n    const others: AnimationPlaybackControls[] = [];\n    let feeding = false;\n    const cb = callbacks.current;\n    const step = (c: AnimationPlaybackControls) => {\n      current = c;\n      return c;\n    };\n    const feed = (on: boolean) => {\n      if (feeding === on) return;\n      feeding = on;\n      callbacks.current.onFeeding(on);\n    };\n\n    const shred = async () => {\n      const entry = SLOT_Y - ROW_H;\n      if (y.get() < entry - 1) {\n        const straighten = animate(x, 0, { duration: 0.3, ease: EASE_IN_OUT });\n        others.push(straighten);\n        await step(\n          flight.velocity\n            ? animate(y, entry, {\n                type: \"spring\",\n                velocity: flight.velocity,\n                stiffness: 300,\n                damping: 34,\n                restDelta: 0.5,\n              })\n            : animate(y, entry, { duration: 0.3, ease: EASE_IN_OUT }),\n        );\n        if (cancelled) return;\n      }\n      feed(true);\n      const remaining = (SLOT_Y - y.get()) / ROW_H;\n      await step(\n        animate(y, SLOT_Y, { duration: FEED * remaining, ease: \"linear\" }),\n      );\n      feed(false);\n      if (cancelled) return;\n      callbacks.current.onShredded();\n      await step(\n        animate(t, 1, {\n          duration: (FALL + MAX_DELAY) * (1 - t.get()),\n          ease: \"linear\",\n        }),\n      );\n      if (!cancelled) callbacks.current.onGone();\n    };\n\n    // Reassembles in about half the time it took to come apart.\n    const restore = async () => {\n      if (t.get() > 0) {\n        await step(animate(t, 0, { duration: 0.2 * t.get(), ease: \"linear\" }));\n        if (cancelled) return;\n      }\n      feed(true);\n      const remaining = (y.get() - (SLOT_Y - ROW_H)) / ROW_H;\n      await step(\n        animate(y, SLOT_Y - ROW_H, {\n          duration: 0.22 * remaining,\n          ease: \"linear\",\n        }),\n      );\n      feed(false);\n      if (!cancelled) callbacks.current.onLanded(y.get());\n    };\n\n    if (flight.mode === \"shred\") shred();\n    else restore();\n    return () => {\n      cancelled = true;\n      current?.stop();\n      others.forEach((c) => c.stop());\n      if (feeding) cb.onFeeding(false);\n    };\n  }, [flight.mode, flight.velocity, x, y, t]);\n\n  return (\n    <>\n      {/* Paper above the slot: this layer's bottom edge is the slot line. */}\n      <div\n        aria-hidden\n        className=\"pointer-events-none absolute inset-x-3 top-0 z-10 overflow-hidden\"\n        style={{ height: SLOT_Y }}\n      >\n        <motion.div\n          className=\"absolute inset-x-0 top-0\"\n          style={{ height: ROW_H, y, x }}\n        >\n          <FileFace file={flight.file} lifted />\n        </motion.div>\n      </div>\n      {/* Strips below the head: this layer's top edge is where they exit. */}\n      <div\n        aria-hidden\n        className=\"pointer-events-none absolute inset-x-3 overflow-hidden\"\n        style={{ top: BIN_TOP, height: BIN_H }}\n      >\n        {flight.falls.map((fall, i) => (\n          <Strip\n            key={i}\n            index={i}\n            fall={fall}\n            depth={depth}\n            t={t}\n            file={flight.file}\n          />\n        ))}\n      </div>\n    </>\n  );\n}\n\nfunction Strip({\n  index,\n  fall,\n  depth,\n  t,\n  file,\n}: {\n  index: number;\n  fall: Fall;\n  depth: MotionValue<number>;\n  t: MotionValue<number>;\n  file: ShredderFile;\n}) {\n  // Each strip's own progress through the shared fall, eased in: gravity.\n  const local = useTransform(t, (v) => {\n    const p = Math.min(\n      Math.max((v * (FALL + MAX_DELAY) - fall.delay) / FALL, 0),\n      1,\n    );\n    return p * p;\n  });\n  const y = useTransform(() => depth.get() - ROW_H + fall.dy * local.get());\n  const rotate = useTransform(local, (p) => fall.rot * p);\n  // Strips drift apart a little as they fall, outward from the middle.\n  const x = useTransform(local, (p) => (index - (STRIPS - 1) / 2) * 1.2 * p);\n  const opacity = useTransform(local, [0, 0.35, 1], [1, 1, 0]);\n  return (\n    <motion.div\n      className=\"absolute top-0 overflow-hidden\"\n      style={{\n        left: `${(index * 100) / STRIPS}%`,\n        width: `${100 / STRIPS}%`,\n        height: ROW_H,\n        y,\n        x,\n        rotate,\n        opacity,\n        originY: 0,\n      }}\n    >\n      <div\n        className=\"absolute top-0 h-full\"\n        style={{\n          left: `${-index * 100}%`,\n          width: `${STRIPS * 100}%`,\n        }}\n      >\n        <FileFace file={file} />\n      </div>\n      {/* The cut between strips. */}\n      <div className=\"absolute inset-y-0 right-0 w-px bg-muted\" />\n    </motion.div>\n  );\n}\n\n// The machine is a physical object, the same in both themes: charcoal\n// plastic with a lighter moulded edge, a black mouth and a green power\n// light.\nconst BODY = \"oklch(0.29 0.006 260)\";\nconst BODY_EDGE = \"oklch(0.4 0.006 260)\";\nconst MOUTH = \"oklch(0.13 0 0)\";\nconst LED_ON = \"oklch(0.8 0.18 145)\";\nconst LED_OFF = \"oklch(0.42 0.02 145)\";\n\n// Seeded, so the pile of strips is the same on the server and the client.\nfunction seeded(seed: number) {\n  let s = seed;\n  return () => {\n    s = (s * 16807) % 2147483647;\n    return (s - 1) / 2147483646;\n  };\n}\n\n// One layer of strips per shredded file, drawn up front and faded in as\n// files land, so the basket fills up and empties on undo. Layer 0 is what\n// was already in the basket, so it never starts out bare.\nconst PILE_LAYERS = 6;\nconst PILE = (() => {\n  const next = seeded(7);\n  return Array.from({ length: PILE_LAYERS }, (_, layer) =>\n    Array.from({ length: 22 }, () => ({\n      x: -10 + next() * 390,\n      y: 41 - layer * 6 - next() * 4,\n      w: 24 + next() * 40,\n      rot: (next() - 0.5) * 24,\n    })),\n  );\n})();\n\nfunction BinBack({ count }: { count: number }) {\n  return (\n    <div\n      aria-hidden\n      className=\"absolute inset-x-2 overflow-hidden rounded-b-[16px] bg-foreground/[0.035] shadow-[inset_0_10px_12px_-8px_oklch(0_0_0/0.3)]\"\n      style={{ top: BIN_TOP, height: BIN_H }}\n    >\n      <svg\n        viewBox=\"0 0 400 48\"\n        preserveAspectRatio=\"none\"\n        className=\"absolute inset-x-0 bottom-0 h-12 w-full\"\n      >\n        {PILE.map((layer, i) => (\n          <g\n            key={i}\n            className={cn(\n              \"transition-opacity ease-out motion-reduce:delay-0\",\n              // A new layer appears once its strips have fallen onto it.\n              i <= count ? \"opacity-100 delay-300 duration-300\" : \"opacity-0 duration-150\",\n            )}\n          >\n            {layer.map((s, j) => (\n              <rect\n                key={j}\n                x={s.x}\n                y={s.y}\n                width={s.w}\n                height={5}\n                rx={1}\n                transform={`rotate(${s.rot} ${s.x + s.w / 2} ${s.y + 2})`}\n                fill=\"var(--background)\"\n                stroke=\"color-mix(in oklab, var(--foreground) 24%, transparent)\"\n                strokeWidth={0.8}\n              />\n            ))}\n          </g>\n        ))}\n      </svg>\n    </div>\n  );\n}\n\n// The basket's wire front, drawn over the falling strips so they land\n// inside it rather than in front of it.\nfunction BinFront() {\n  return (\n    <div\n      aria-hidden\n      className=\"pointer-events-none absolute inset-x-2 rounded-b-[16px] border-x-[1.5px] border-b-[1.5px] border-foreground/15\"\n      style={{\n        top: BIN_TOP,\n        height: BIN_H,\n        backgroundImage:\n          \"repeating-linear-gradient(90deg, color-mix(in oklab, var(--foreground) 9%, transparent) 0 1.5px, transparent 1.5px 12px)\",\n      }}\n    />\n  );\n}\n\nfunction Head({ running }: { running: boolean }) {\n  return (\n    <motion.div\n      aria-hidden\n      className=\"absolute inset-x-0 z-[5] flex items-end justify-between rounded-t-[10px] rounded-b-[8px] px-4 pb-2.5\"\n      style={{\n        top: LIST_H + HEAD_GAP,\n        height: HEAD_H,\n        background: BODY,\n        boxShadow: `inset 0 1px 0 ${BODY_EDGE}, 0 6px 14px -6px oklch(0 0 0 / 0.45)`,\n      }}\n      // A sub-pixel buzz: enough to feel the motor, not enough to blur text.\n      animate={\n        running\n          ? { x: [0, -0.7, 0.6, -0.3, 0], y: [0, 0.3, -0.2, 0.3, 0] }\n          : { x: 0, y: 0 }\n      }\n      transition={\n        running\n          ? { duration: 0.12, repeat: Infinity, ease: \"linear\" }\n          : { duration: 0.1 }\n      }\n    >\n      {/* The mouth: a black slot under a moulded lip, centred on SLOT_Y. */}\n      <div\n        className=\"absolute inset-x-2.5 top-[5px] h-1.5 rounded-full\"\n        style={{\n          background: MOUTH,\n          boxShadow: `0 1px 0 ${BODY_EDGE}`,\n        }}\n      />\n      <span className=\"text-xs font-semibold tracking-[0.24em] text-[oklch(1_0_0/0.4)]\">\n        SHRED\n      </span>\n      <span className=\"flex items-center gap-2 text-xs font-medium tracking-wide text-[oklch(1_0_0/0.4)]\">\n        AUTO\n        <span\n          className=\"size-1.5 rounded-full transition-[background-color,box-shadow] duration-150 ease-out\"\n          style={{\n            background: running ? LED_ON : LED_OFF,\n            boxShadow: running ? `0 0 6px ${LED_ON}` : \"none\",\n          }}\n        />\n      </span>\n    </motion.div>\n  );\n}\n\nfunction TrashIcon() {\n  return (\n    <svg\n      aria-hidden\n      viewBox=\"0 0 16 16\"\n      className=\"size-4\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth={1.5}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n    >\n      <path d=\"M2.75 4.25h10.5M6.25 4.25v-1.5h3.5v1.5M4 4.25l.6 8.1a1 1 0 0 0 1 .9h4.8a1 1 0 0 0 1-.9l.6-8.1M6.75 7v3.75M9.25 7v3.75\" />\n    </svg>\n  );\n}\n\nconst DEMO_FILES: ShredderFile[] = [\n  { id: \"q3\", name: \"Q3 report.pdf\", meta: \"PDF, 2.4 MB\" },\n  { id: \"invoice\", name: \"Invoice 0142.pdf\", meta: \"PDF, 184 KB\" },\n  { id: \"contract\", name: \"Contract draft.docx\", meta: \"Word, 96 KB\" },\n  { id: \"tax\", name: \"Tax return 2025.pdf\", meta: \"PDF, 1.1 MB\" },\n];\n\nexport default function PaperShredderDemo() {\n  return <PaperShredder files={DEMO_FILES} />;\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"}],"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":["objects"],"docs":"From ui lab: https://lab.xevrion.dev/lab/paper-shredder"}