{
	"$schema": "https://ui.shadcn.com/schema/registry-item.json",
	"name": "CodeSlots-TS-CSS",
	"title": "CodeSlots",
	"description": "One-time-code input where a hidden overlay input owns focus, paste and SMS autofill while each slot lands its digit on one spring: the fill swells from the centre, the digit rises and the caret glides; a wrong code drains the slots in a cascade, a right one merges them into a single accent wash.",
	"type": "registry:component",
	"files": [
		{
			"type": "registry:file",
			"path": "CodeSlots.css",
			"target": "@components/CodeSlots.css",
			"content": ".code-slots {\n  --cs-accent: #f5f5f5;\n  --cs-ink: #f5f5f5;\n  --cs-slot: #27272a;\n  --cs-digit: #18181b;\n  --cs-danger: #ff3b30;\n  --cs-size: 44px;\n  --cs-height: 52px;\n  --cs-gap: 8px;\n  --cs-radius: 12px;\n  --cs-font: 22px;\n\n  position: relative;\n  display: inline-block;\n}\n\n.code-slots__row {\n  position: relative;\n  display: inline-flex;\n  gap: var(--cs-gap);\n  cursor: text;\n  touch-action: manipulation;\n  -webkit-tap-highlight-color: transparent;\n  transition: opacity 200ms ease;\n}\n\n.code-slots__row[data-disabled] {\n  opacity: 0.5;\n  cursor: not-allowed;\n}\n\n.code-slots__input {\n  position: absolute;\n  inset: 0;\n  z-index: 4;\n  margin: 0;\n  padding: 0;\n  border: 0;\n  outline: 0;\n  background: transparent;\n  color: transparent;\n  caret-color: transparent;\n  font-size: 16px;\n  opacity: 0;\n  cursor: inherit;\n  -webkit-appearance: none;\n  appearance: none;\n}\n\n.code-slots__wash {\n  position: absolute;\n  inset: 0;\n  z-index: 1;\n  display: grid;\n  place-items: center;\n  border-radius: var(--cs-radius);\n  background: var(--cs-accent);\n  color: var(--cs-digit);\n  pointer-events: none;\n}\n\n.code-slots__check {\n  display: grid;\n  place-items: center;\n}\n\n.code-slots__slot {\n  position: relative;\n  width: var(--cs-size);\n  height: var(--cs-height);\n  border-radius: var(--cs-radius);\n  overflow: hidden;\n  background: var(--cs-slot);\n  transition: background-color 200ms ease;\n  user-select: none;\n  -webkit-user-select: none;\n}\n\n.code-slots__fill {\n  position: absolute;\n  inset: 0;\n  border-radius: inherit;\n  background: var(--cs-accent);\n  transform-origin: center;\n  transition: background-color 200ms ease;\n}\n\n@media (hover: hover) and (pointer: fine) {\n  .code-slots__row[data-status='idle']:not([data-disabled]):hover .code-slots__slot:not([data-active]) {\n    background: color-mix(in srgb, var(--cs-ink) 4%, var(--cs-slot));\n  }\n}\n\n.code-slots__slot[data-active] {\n  background: color-mix(in srgb, var(--cs-ink) 8%, var(--cs-slot));\n}\n\n.code-slots__row[data-status='error'] .code-slots__slot {\n  background: color-mix(in srgb, var(--cs-danger) 20%, var(--cs-slot));\n}\n\n.code-slots__row[data-status='error'] .code-slots__fill {\n  background: var(--cs-danger);\n}\n\n.code-slots__digit {\n  position: absolute;\n  inset: 0;\n  z-index: 2;\n  display: grid;\n  place-items: center;\n  color: var(--cs-digit);\n  font-family: inherit;\n  font-size: var(--cs-font);\n  font-weight: 600;\n  line-height: 1;\n  font-variant-numeric: tabular-nums;\n}\n\n.code-slots__caret {\n  position: absolute;\n  z-index: 3;\n  top: 25%;\n  left: calc(var(--cs-size) / 2 - 0.75px);\n  width: 1.5px;\n  height: 50%;\n  opacity: 0;\n  pointer-events: none;\n}\n\n.code-slots__caret[data-show] {\n  opacity: 1;\n}\n\n.code-slots__caret-line {\n  display: block;\n  width: 100%;\n  height: 100%;\n  background: var(--cs-ink);\n  animation: code-slots-blink 1s linear infinite;\n}\n\n@keyframes code-slots-blink {\n  0%,\n  49.9% {\n    opacity: 1;\n  }\n  50%,\n  100% {\n    opacity: 0;\n  }\n}\n\n.code-slots__sr {\n  position: absolute;\n  width: 1px;\n  height: 1px;\n  margin: -1px;\n  padding: 0;\n  border: 0;\n  overflow: hidden;\n  clip: rect(0 0 0 0);\n  white-space: nowrap;\n}\n\n@media (prefers-reduced-motion: reduce) {\n  .code-slots__digit,\n  .code-slots__check {\n    transform: none !important;\n    transition: opacity 150ms ease;\n  }\n\n  .code-slots__caret-line {\n    animation: none;\n  }\n\n  .code-slots__wash {\n    opacity: 0;\n    transition: opacity 200ms ease;\n  }\n\n  .code-slots__row[data-status='success'] .code-slots__wash {\n    opacity: 1;\n  }\n}\n"
		},
		{
			"type": "registry:component",
			"path": "CodeSlots.tsx",
			"content": "'use client';\n\nimport React, { useCallback, useEffect, useId, useMemo, useRef, useState, type CSSProperties } from 'react';\nimport {\n  animate,\n  motion,\n  motionValue,\n  useMotionValue,\n  useReducedMotion,\n  useTransform,\n  type MotionValue\n} from 'motion/react';\nimport { HugeiconsIcon } from '@hugeicons/react';\nimport { Tick02Icon } from '@hugeicons/core-free-icons';\n\nimport './CodeSlots.css';\n\nconst EASE_OUT: [number, number, number, number] = [0.23, 1, 0.32, 1];\nconst WASH_IN = 0.3;\nconst WASH_OUT = 0.2;\nconst SINK_DELAY = 0.06;\nconst SINK_STEP = 0.03;\nconst CHECK_DELAY = 0.28;\nconst CHECK_RISE = 8;\nconst SINK_FADE = 0.6;\n\nexport type CodeSlotsStatus = 'idle' | 'error' | 'success';\n\nexport interface CodeSlotsProps {\n  length?: number;\n  value?: string;\n  defaultValue?: string;\n  onChange?: (code: string) => void;\n  onComplete?: (code: string) => void;\n  status?: CodeSlotsStatus;\n  mask?: boolean;\n  caret?: boolean;\n  disabled?: boolean;\n  autoFocus?: boolean;\n  accentColor?: string;\n  inkColor?: string;\n  slotColor?: string;\n  digitColor?: string;\n  dangerColor?: string;\n  slotSize?: number;\n  gap?: number;\n  radius?: number;\n  bounce?: number;\n  settle?: number;\n  rise?: number;\n  cascade?: number;\n  ariaLabel?: string;\n  className?: string;\n}\n\ninterface Live {\n  settle: number;\n  bounce: number;\n  cascade: number;\n  reduce: boolean | null;\n}\n\ninterface SlotProps {\n  mv: MotionValue<number>;\n  drop: MotionValue<number>;\n  char: string;\n  active: boolean;\n  rise: number;\n  sink: number;\n}\n\nconst clamp01 = (v: number) => Math.min(1, Math.max(0, v));\nconst digitsOf = (raw: string | undefined) => String(raw ?? '').replace(/\\D/g, '');\nconst toSlots = (raw: string | undefined, n: number) => {\n  const d = digitsOf(raw).slice(0, n);\n  return Array.from({ length: n }, (_, i) => d[i] ?? '');\n};\nconst firstEmptyOf = (slots: string[]) => {\n  const i = slots.indexOf('');\n  return i === -1 ? slots.length - 1 : i;\n};\nconst isFull = (slots: string[]) => slots.every(Boolean);\n\nconst CodeSlots: React.FC<CodeSlotsProps> = ({\n  length = 6,\n  value,\n  defaultValue = '',\n  onChange,\n  onComplete,\n  status = 'idle',\n  mask = false,\n  caret = true,\n  disabled = false,\n  autoFocus = false,\n  accentColor = '#f5f5f5',\n  inkColor = '#f5f5f5',\n  slotColor = '#27272a',\n  digitColor = '#18181b',\n  dangerColor = '#ff3b30',\n  slotSize = 44,\n  gap = 8,\n  radius = 12,\n  bounce = 0.2,\n  settle = 0.3,\n  rise = 8,\n  cascade = 20,\n  ariaLabel = 'One-time code',\n  className = ''\n}) => {\n  const uid = useId();\n  const reduce = useReducedMotion();\n  const inputRef = useRef<HTMLInputElement>(null);\n  const rowRef = useRef<HTMLDivElement>(null);\n  const [slots, setSlots] = useState(() => toSlots(value ?? defaultValue, length));\n  const [active, setActive] = useState(() => firstEmptyOf(slots));\n  const [focused, setFocused] = useState(false);\n  const [veiled, setVeiled] = useState(status === 'success');\n  const activeMv = useMotionValue(active);\n  const openMv = useMotionValue(status === 'success' ? 1 : 0);\n  const checkMv = useMotionValue(status === 'success' ? 1 : 0);\n  const glide = useRef<Set<number>>(new Set());\n  const target = useRef<number[]>([]);\n  const draining = useRef(false);\n  const drainTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n  const statusRef = useRef(status);\n  const emitted = useRef(digitsOf(value ?? defaultValue).slice(0, length));\n  const slotsRef = useRef(slots);\n  slotsRef.current = slots;\n  const live = useRef<Live>({} as Live);\n  live.current = { settle, bounce, cascade, reduce };\n\n  const springs = useMemo(\n    () => ({\n      mvs: Array.from({ length }, (_, i) => motionValue(slotsRef.current[i] ? 1 : 0)),\n      drops: Array.from({ length }, () => motionValue(statusRef.current === 'success' ? 1 : 0))\n    }),\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n    [length]\n  );\n  const { mvs, drops } = springs;\n  const pitch = slotSize + gap;\n  const height = Math.round(slotSize * 1.18);\n  const washRadius = Math.min(radius, slotSize / 2);\n\n  const drive = useCallback(\n    (i: number, to: number, delayMs = 0) => {\n      const mv = mvs[i];\n      if (!mv) return;\n      target.current[i] = to;\n      const L = live.current;\n      if (L.reduce) {\n        mv.jump(to);\n        return;\n      }\n      animate(mv, to, { type: 'spring', duration: L.settle, bounce: L.bounce, delay: delayMs / 1000 });\n    },\n    [mvs]\n  );\n  const land = useCallback(\n    (i: number, delayMs = 0) => {\n      if (mvs[i].get() > 0) mvs[i].jump(0);\n      drive(i, 1, delayMs);\n    },\n    [mvs, drive]\n  );\n  const moveActive = useCallback(\n    (next: number, crossed: number[]) => {\n      crossed.forEach(j => glide.current.add(j));\n      activeMv.jump(next);\n      setActive(next);\n    },\n    [activeMv]\n  );\n  const jumpActive = useCallback(\n    (next: number) => {\n      glide.current.clear();\n      activeMv.jump(next);\n      setActive(next);\n    },\n    [activeMv]\n  );\n\n  const caretX = useTransform(() => {\n    const a = activeMv.get();\n    let x = a * pitch;\n    for (let j = 0; j < mvs.length; j++) {\n      const h = clamp01(mvs[j].get());\n      if (!glide.current.has(j)) continue;\n      const to = target.current[j];\n      if (to === undefined || h === clamp01(to)) {\n        glide.current.delete(j);\n        continue;\n      }\n      x += j < a ? -(1 - h) * pitch : h * pitch;\n    }\n    return Math.min(Math.max(x, 0), (mvs.length - 1) * pitch);\n  });\n  const caretTransform = useTransform(caretX, (x: number) => `translateX(${x}px)`);\n  const washClip = useTransform(openMv, (o: number) => `inset(0 ${(1 - clamp01(o)) * 50}% round ${washRadius}px)`);\n  const checkTransform = useTransform(\n    checkMv,\n    (c: number) => `translateY(${(1 - c) * CHECK_RISE}px) scale(${0.85 + 0.15 * Math.max(c, 0)})`\n  );\n  const checkOpacity = useTransform(checkMv, clamp01);\n\n  const commit = useCallback(\n    (next: string[]) => {\n      const prev = slotsRef.current;\n      slotsRef.current = next;\n      setSlots(next);\n      const code = next.join('');\n      emitted.current = code;\n      onChange?.(code);\n      if (!isFull(prev) && isFull(next)) onComplete?.(code);\n    },\n    [onChange, onComplete]\n  );\n\n  const insert = (raw: string, from = active) => {\n    const digits = digitsOf(raw);\n    if (!digits) return;\n    const next = [...slotsRef.current];\n    const crossed: number[] = [];\n    const step = reduce ? 0 : cascade;\n    let i = from;\n    for (const ch of digits) {\n      if (i >= length) break;\n      next[i] = ch;\n      land(i, (i - from) * step);\n      crossed.push(i);\n      i += 1;\n    }\n    if (!crossed.length) return;\n    commit(next);\n    moveActive(Math.min(i, length - 1), crossed);\n  };\n  const clearSlot = (i: number, stepBack = false) => {\n    if (!slotsRef.current[i]) {\n      if (stepBack) jumpActive(i);\n      return;\n    }\n    const next = [...slotsRef.current];\n    next[i] = '';\n    drive(i, 0);\n    commit(next);\n    if (stepBack) moveActive(i, [i]);\n  };\n\n  const busy = disabled || draining.current || status === 'success';\n  const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {\n    if (busy || e.metaKey || e.ctrlKey || e.altKey) return;\n    const k = e.key;\n    if (/^[0-9]$/.test(k)) {\n      e.preventDefault();\n      insert(k);\n    } else if (k === 'Backspace') {\n      e.preventDefault();\n      if (slots[active]) clearSlot(active);\n      else if (active > 0) clearSlot(active - 1, true);\n    } else if (k === 'Delete') {\n      e.preventDefault();\n      clearSlot(active);\n    } else if (k === 'ArrowLeft') {\n      e.preventDefault();\n      jumpActive(Math.max(active - 1, 0));\n    } else if (k === 'ArrowRight') {\n      e.preventDefault();\n      jumpActive(Math.min(active + 1, length - 1));\n    } else if (k === 'Home') {\n      e.preventDefault();\n      jumpActive(0);\n    } else if (k === 'End') {\n      e.preventDefault();\n      jumpActive(length - 1);\n    }\n  };\n  const onPaste = (e: React.ClipboardEvent<HTMLInputElement>) => {\n    if (busy) return;\n    e.preventDefault();\n    insert(e.clipboardData.getData('text'));\n  };\n  const onInput = (e: React.ChangeEvent<HTMLInputElement>) => {\n    if (busy) return;\n    const d = digitsOf(e.target.value);\n    if (!d) return;\n    insert(d, d.length === 1 ? active : 0);\n  };\n  const onRowMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {\n    if (disabled) return;\n    e.preventDefault();\n    const row = rowRef.current;\n    if (row && !draining.current && status !== 'success') {\n      const rect = row.getBoundingClientRect();\n      const zoom = rect.width / (row.offsetWidth || rect.width) || 1;\n      const i = Math.floor((e.clientX - rect.left) / zoom / pitch);\n      jumpActive(Math.max(0, Math.min(i, firstEmptyOf(slotsRef.current))));\n    }\n    inputRef.current?.focus();\n  };\n\n  useEffect(() => {\n    glide.current.clear();\n    target.current = [];\n    const next = Array.from({ length }, (_, i) => slotsRef.current[i] ?? '');\n    slotsRef.current = next;\n    setSlots(next);\n    jumpActive(firstEmptyOf(next));\n    const code = next.join('');\n    if (code !== emitted.current) {\n      emitted.current = code;\n      onChange?.(code);\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [length]);\n\n  useEffect(() => {\n    if (value === undefined) return;\n    const clean = digitsOf(value).slice(0, length);\n    if (clean === emitted.current) return;\n    emitted.current = clean;\n    const prev = slotsRef.current;\n    const next = toSlots(clean, length);\n    const hidden = statusRef.current === 'success';\n    const landing: number[] = [];\n    const leaving: number[] = [];\n    next.forEach((ch, i) => {\n      if (ch === prev[i]) return;\n      (ch ? landing : leaving).push(i);\n    });\n    const step = live.current.reduce || hidden ? 0 : live.current.cascade;\n    landing.forEach((i, k) => land(i, k * step));\n    leaving.reverse().forEach((i, k) => {\n      if (hidden) {\n        target.current[i] = 0;\n        mvs[i].jump(0);\n        drops[i].jump(0);\n      } else drive(i, 0, k * step);\n    });\n    slotsRef.current = next;\n    setSlots(next);\n    moveActive(firstEmptyOf(next), [...landing, ...leaving]);\n    if (!isFull(prev) && isFull(next)) onComplete?.(clean);\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [value, length]);\n\n  useEffect(() => {\n    const was = statusRef.current;\n    const L = live.current;\n    if (status === 'success') {\n      setVeiled(true);\n      if (L.reduce) {\n        openMv.jump(1);\n        drops.forEach(d => d.jump(1));\n        checkMv.jump(1);\n        return;\n      }\n      animate(openMv, 1, { duration: WASH_IN, ease: EASE_OUT });\n      drops.forEach((d, k) =>\n        animate(d, 1, { type: 'spring', duration: 0.3, bounce: 0, delay: SINK_DELAY + k * SINK_STEP })\n      );\n      animate(checkMv, 1, { type: 'spring', duration: 0.35, bounce: L.bounce, delay: CHECK_DELAY });\n      return;\n    }\n    if (was !== 'success') return;\n    if (L.reduce) {\n      openMv.jump(0);\n      checkMv.jump(0);\n      drops.forEach(d => d.jump(0));\n      setVeiled(false);\n      return;\n    }\n    animate(checkMv, 0, { duration: 0.15, ease: EASE_OUT });\n    animate(openMv, 0, { duration: WASH_OUT, ease: EASE_OUT, delay: 0.06 }).then(() => {\n      if (openMv.get() === 0) setVeiled(false);\n    });\n    drops.forEach(d => animate(d, 0, { type: 'spring', duration: 0.3, bounce: 0, delay: 0.1 }));\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [status]);\n\n  useEffect(() => {\n    if (status !== 'error') return;\n    const filled = slotsRef.current.map((c, i) => (c ? i : -1)).filter(i => i >= 0);\n    if (!filled.length) return;\n    filled.reverse();\n    draining.current = true;\n    const L = live.current;\n    const step = L.reduce ? 0 : L.cascade;\n    filled.forEach((i, k) => drive(i, 0, k * step));\n    moveActive(\n      0,\n      slotsRef.current.map((_, j) => j)\n    );\n    clearTimeout(drainTimer.current);\n    drainTimer.current = setTimeout(\n      () => {\n        draining.current = false;\n        commit(Array.from({ length }, () => ''));\n      },\n      L.reduce ? 300 : (filled.length - 1) * step + L.settle * 1000\n    );\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [status]);\n  useEffect(() => {\n    statusRef.current = status;\n  }, [status]);\n  useEffect(() => () => clearTimeout(drainTimer.current), []);\n  useEffect(() => {\n    if (autoFocus) inputRef.current?.focus();\n  }, [autoFocus]);\n\n  const view = slots.length === length ? slots : Array.from({ length }, (_, i) => slots[i] ?? '');\n  const showCaret =\n    caret && focused && !disabled && !veiled && status !== 'success' && (status === 'error' || !view[active]);\n\n  return (\n    <div\n      className={`code-slots${className ? ` ${className}` : ''}`}\n      style={\n        {\n          '--cs-accent': accentColor,\n          '--cs-ink': inkColor,\n          '--cs-slot': slotColor,\n          '--cs-digit': digitColor,\n          '--cs-danger': dangerColor,\n          '--cs-size': `${slotSize}px`,\n          '--cs-height': `${height}px`,\n          '--cs-gap': `${gap}px`,\n          '--cs-radius': `${Math.min(radius, slotSize / 2)}px`,\n          '--cs-font': `${Math.round(slotSize * 0.5)}px`\n        } as CSSProperties\n      }\n    >\n      <div\n        ref={rowRef}\n        className=\"code-slots__row\"\n        data-status={status}\n        data-focused={focused ? '' : undefined}\n        data-disabled={disabled ? '' : undefined}\n        onMouseDown={onRowMouseDown}\n      >\n        <input\n          ref={inputRef}\n          className=\"code-slots__input\"\n          type=\"text\"\n          inputMode=\"numeric\"\n          autoComplete=\"one-time-code\"\n          pattern=\"[0-9]*\"\n          value=\"\"\n          maxLength={length}\n          aria-label={ariaLabel}\n          aria-invalid={status === 'error'}\n          aria-describedby={`${uid}-count`}\n          disabled={disabled}\n          readOnly={status === 'success'}\n          onKeyDown={onKeyDown}\n          onPaste={onPaste}\n          onChange={onInput}\n          onFocus={() => setFocused(true)}\n          onBlur={() => setFocused(false)}\n        />\n        {view.map((ch, i) => (\n          <Slot\n            key={i}\n            mv={mvs[i]}\n            drop={drops[i]}\n            char={mask && ch ? '•' : ch}\n            active={focused && i === active}\n            rise={rise}\n            sink={Math.round(height * 0.5)}\n          />\n        ))}\n        <motion.span className=\"code-slots__wash\" aria-hidden=\"true\" style={{ clipPath: washClip }}>\n          <motion.span className=\"code-slots__check\" style={{ transform: checkTransform, opacity: checkOpacity }}>\n            <HugeiconsIcon icon={Tick02Icon} size={Math.round(slotSize * 0.6)} strokeWidth={2.2} />\n          </motion.span>\n        </motion.span>\n        <motion.span\n          className=\"code-slots__caret\"\n          aria-hidden=\"true\"\n          data-show={showCaret ? '' : undefined}\n          style={{ transform: caretTransform }}\n        >\n          <span key={active} className=\"code-slots__caret-line\" />\n        </motion.span>\n      </div>\n      <span id={`${uid}-count`} className=\"code-slots__sr\" aria-live=\"polite\">\n        {status === 'success' ? 'Code accepted' : `${view.filter(Boolean).length} of ${length} digits entered`}\n      </span>\n    </div>\n  );\n};\n\nfunction Slot({ mv, drop, char, active, rise, sink }: SlotProps) {\n  const [shown, setShown] = useState(char);\n  if (char && char !== shown) setShown(char);\n  const fill = useTransform(mv, (t: number) => `scale(${Math.max(t, 0)})`);\n  const lift = useTransform(\n    [mv, drop],\n    ([t, d]: number[]) => `translateY(${(1 - t) * rise + Math.max(d, 0) * sink}px)`\n  );\n  const ink = useTransform([mv, drop], ([t, d]: number[]) => clamp01(t) * (1 - clamp01(d / SINK_FADE)));\n  return (\n    <span\n      className=\"code-slots__slot\"\n      data-active={active ? '' : undefined}\n      data-filled={char ? '' : undefined}\n      aria-hidden=\"true\"\n    >\n      <motion.span className=\"code-slots__fill\" style={{ transform: fill }} />\n      {shown ? (\n        <motion.span className=\"code-slots__digit\" style={{ transform: lift, opacity: ink }}>\n          {shown}\n        </motion.span>\n      ) : null}\n    </span>\n  );\n}\n\nexport default CodeSlots;\n"
		}
	],
	"registryDependencies": [],
	"dependencies": [
		"motion@^12.23.12",
		"@hugeicons/react@^1.1.10",
		"@hugeicons/core-free-icons@^4.3.3"
	]
}