{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "username-reel",
  "type": "registry:ui",
  "title": "Username Reel",
  "description": "A slot-machine handle picker that spins a vertical reel of names past a fixed prefix, highlighting the centered one, then decelerates and lands on a final username, built with Motion.",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/crafterui/ui/username-reel.tsx",
      "type": "registry:ui",
      "content": "\"use client\"\n\n// Inspired by the bento.me username picker hero animation.\nimport * as React from \"react\"\nimport { animate, motion, useMotionValue } from \"motion/react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst DEFAULT_NAMES = [\n  \"adeline\",\n  \"dennis\",\n  \"michele\",\n  \"eike\",\n  \"may-li\",\n  \"clara\",\n  \"tito\",\n  \"silvan\",\n  \"noor\",\n  \"ravi\",\n  \"yuki\",\n  \"mateo\",\n]\n\ntype Phase = \"spinning\" | \"landed\" | \"done\"\n\nexport interface UsernameReelProps {\n  /** Names that scroll past in the reel. @default a built-in sample set */\n  names?: string[]\n  /** The handle the reel lands on. @default \"username\" */\n  finalName?: string\n  /** Static prefix shown before the reel, e.g. a domain. @default \"bento.me/\" */\n  prefix?: string\n  /** Visible rows in the reel viewport (odd numbers center cleanly). @default 7 */\n  rows?: number\n  /** How many times `names` repeats before the final handle (more = longer scroll). @default 3 */\n  cycles?: number\n  /** Scroll duration in seconds before it settles. @default 4.5 */\n  spinDuration?: number\n  /** Color the final handle pulses to as it lands (any CSS color). @default \"#6366f1\" */\n  highlightColor?: string\n  /** Color of the names while scrolling. @default \"var(--muted-foreground)\" */\n  placeholderColor?: string\n  /** Replay the whole scroll on a loop (great for previews). @default false */\n  loop?: boolean\n  /** Pause in ms on the final handle before replaying when `loop`. @default 2000 */\n  loopDelay?: number\n  /** Extra classes for the outer container. @default undefined */\n  className?: string\n}\n\nfunction shuffle<T>(input: T[]): T[] {\n  const arr = [...input]\n  for (let i = arr.length - 1; i > 0; i--) {\n    const j = Math.floor(Math.random() * (i + 1))\n    ;[arr[i], arr[j]] = [arr[j], arr[i]]\n  }\n  return arr\n}\n\nexport function UsernameReel({\n  names = DEFAULT_NAMES,\n  finalName = \"username\",\n  prefix = \"bento.me/\",\n  rows = 7,\n  cycles = 3,\n  spinDuration = 4.5,\n  highlightColor = \"#6366f1\",\n  placeholderColor = \"var(--muted-foreground)\",\n  loop = false,\n  loopDelay = 2000,\n  className,\n}: UsernameReelProps) {\n  const [runId, setRunId] = React.useState(0)\n  const [rowH, setRowH] = React.useState(0)\n  const [phase, setPhase] = React.useState<Phase>(\"spinning\")\n\n  const y = useMotionValue(0)\n  const measureRef = React.useRef<HTMLDivElement>(null)\n\n  // The handle sits just below the cycles (with a little filler beneath it so the\n  // viewport stays full). The scroll runs DOWN the reel and settles onto it last,\n  // so the username descends from the top and lands at the very end.\n  const half = Math.floor(rows / 2)\n  const reel = React.useMemo(() => {\n    const filler = shuffle(names).slice(0, half)\n    const list: string[] = [...filler, finalName]\n    for (let c = 0; c < Math.max(1, cycles); c++) list.push(...shuffle(names))\n    return list\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [names, finalName, cycles, half, runId])\n\n  const finalIndex = half\n  const lastIndex = reel.length - 1\n  const startIndex = Math.max(finalIndex + 1, lastIndex - half)\n  const viewportH = rowH * rows\n\n  const offsetFor = React.useCallback(\n    (i: number) => viewportH / 2 - (i * rowH + rowH / 2),\n    [viewportH, rowH]\n  )\n\n  // Measure one row so the geometry is font-size agnostic.\n  React.useLayoutEffect(() => {\n    const h = measureRef.current?.offsetHeight ?? 0\n    if (h && h !== rowH) setRowH(h)\n  }, [rowH])\n\n  // Drive the scroll: start on the last row, glide down, settle on the handle.\n  React.useEffect(() => {\n    if (!rowH) return\n    setPhase(\"spinning\")\n\n    const prefersReduced =\n      typeof window !== \"undefined\" &&\n      !!window.matchMedia?.(\"(prefers-reduced-motion: reduce)\").matches\n\n    let landTimer: number | undefined\n    let loopTimer: number | undefined\n\n    if (prefersReduced) {\n      y.set(offsetFor(finalIndex))\n      setPhase(\"done\")\n      return\n    }\n\n    y.set(offsetFor(startIndex))\n    const controls = animate(y, offsetFor(finalIndex), {\n      duration: spinDuration,\n      // Gentle ease-in-out — smooth start, smooth stop, no whip.\n      ease: [0.65, 0, 0.35, 1],\n      onComplete: () => {\n        setPhase(\"landed\") // username pulses to the highlight color\n        landTimer = window.setTimeout(() => {\n          setPhase(\"done\") // then eases back to the normal color\n          if (loop) {\n            loopTimer = window.setTimeout(\n              () => setRunId((r) => r + 1),\n              loopDelay\n            )\n          }\n        }, 850)\n      },\n    })\n\n    return () => {\n      controls.stop()\n      window.clearTimeout(landTimer)\n      window.clearTimeout(loopTimer)\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [rowH, runId])\n\n  const finalColor =\n    phase === \"landed\"\n      ? highlightColor\n      : phase === \"done\"\n        ? \"var(--foreground)\"\n        : placeholderColor\n\n  return (\n    <div\n      className={cn(\n        \"bg-background text-foreground flex h-full w-full items-center justify-center overflow-hidden px-6\",\n        className\n      )}\n    >\n      <div className=\"flex items-center text-4xl font-medium tracking-tight sm:text-6xl\">\n        <span className=\"text-muted-foreground shrink-0 whitespace-nowrap\">\n          {prefix}\n        </span>\n\n        <div className=\"relative overflow-hidden\" style={{ height: viewportH }}>\n          {/* Hidden probe to measure a single row's height. */}\n          <div\n            ref={measureRef}\n            aria-hidden\n            className=\"pointer-events-none invisible absolute leading-[1.2] whitespace-nowrap\"\n          >\n            {finalName}\n          </div>\n\n          <motion.div\n            style={{ y }}\n            className={cn(\n              \"transform-gpu will-change-transform\",\n              rowH ? \"\" : \"opacity-0\"\n            )}\n          >\n            {reel.map((name, i) => {\n              const isFinal = i === finalIndex\n              return (\n                <div\n                  key={i}\n                  className=\"leading-[1.2] whitespace-nowrap transition-[color,opacity] duration-500 ease-out\"\n                  style={{\n                    color: isFinal ? finalColor : placeholderColor,\n                    opacity: !isFinal && phase !== \"spinning\" ? 0 : 1,\n                  }}\n                >\n                  {name}\n                </div>\n              )\n            })}\n          </motion.div>\n\n          {/* Clean edge fades (background overlays — cheaper than a mask). */}\n          <div\n            aria-hidden\n            className=\"pointer-events-none absolute inset-x-0 top-0\"\n            style={{\n              height: rowH * 1.5,\n              background:\n                \"linear-gradient(to bottom, var(--background), transparent)\",\n            }}\n          />\n          <div\n            aria-hidden\n            className=\"pointer-events-none absolute inset-x-0 bottom-0\"\n            style={{\n              height: rowH * 1.5,\n              background:\n                \"linear-gradient(to top, var(--background), transparent)\",\n            }}\n          />\n        </div>\n      </div>\n    </div>\n  )\n}\n"
    }
  ]
}
