{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "pin-input",
  "title": "Pin Input",
  "type": "registry:ui",
  "files": [
    {
      "path": "packages/registry-svelte/components/pin-input/PinInput.svelte",
      "content": "<script lang=\"ts\" module>\n  import type { Snippet } from 'svelte'\n  import type { HTMLAttributes } from 'svelte/elements'\n\n  export type PinInputStatus = 'error' | 'warning' | 'success' | 'default'\n  export type PinInputSize = 'sm' | 'md' | 'lg'\n  export type PinInputType = 'text' | 'number'\n\n  export interface PinInputProps extends HTMLAttributes<HTMLDivElement> {\n    /** Controlled slot values. Pair with `bind:value`. */\n    value?: string[] | undefined\n    defaultValue?: string[]\n    /** Slot count (React `maxLength` parity). Wins over `length` when both are set. Defaults to the number of registered slots. */\n    maxLength?: number\n    /** Slot count. Defaults to the number of registered slots.\n     * @deprecated Use `maxLength`. Kept as an alias — both stay functional. */\n    length?: number\n    placeholder?: string\n    otp?: boolean\n    mask?: boolean\n    autoSubmit?: boolean\n    status?: PinInputStatus\n    size?: PinInputSize\n    type?: PinInputType\n    disabled?: boolean\n    /** Form field name. Rendered as a hidden input carrying the joined code. */\n    name?: string\n    children?: Snippet\n    onComplete?: (value: string) => void\n    onValueChange?: (value: string[]) => void\n    ref?: HTMLDivElement | null\n  }\n\n  export interface PinInputContext {\n    readonly value: string[]\n    readonly placeholder: string\n    readonly mask: boolean\n    readonly status: PinInputStatus\n    readonly size: PinInputSize\n    readonly type: PinInputType\n    readonly otp: boolean\n    readonly disabled: boolean\n    readonly length: number\n    register: (index: number, el: HTMLInputElement) => void\n    unregister: (index: number) => void\n    handleSlotInput: (index: number, raw: string) => void\n    handleSlotKeyDown: (event: KeyboardEvent, index: number) => void\n    handleSlotPaste: (event: ClipboardEvent, index: number) => void\n    focusSlot: (index: number) => void\n  }\n\n  export const PIN_INPUT_CTX = Symbol('uipkge-pin-input')\n</script>\n\n<script lang=\"ts\">\n  import { setContext, tick } from 'svelte'\n  import { cn } from '$lib/utils'\n\n  let {\n    class: className,\n    value = $bindable(undefined),\n    defaultValue = [],\n    maxLength,\n    length,\n    placeholder = '',\n    otp = true,\n    mask = false,\n    autoSubmit = false,\n    status = 'default',\n    size = 'md',\n    type = 'text',\n    disabled = false,\n    name,\n    children,\n    onComplete,\n    onValueChange,\n    ref = $bindable(null),\n    ...restProps\n  }: PinInputProps = $props()\n\n  // svelte-ignore state_referenced_locally -- uncontrolled seed: later defaultValue changes must not clobber typing.\n  let internalValue = $state<string[]>([...defaultValue])\n  const currentValue = $derived(value ?? internalValue)\n  const slotEls = new Map<number, HTMLInputElement>()\n  let slotCount = $state(0)\n  const effectiveLength = $derived((maxLength ?? length) ?? Math.max(slotCount, currentValue.length, 1))\n  const joinedValue = $derived(\n    Array.from({ length: effectiveLength }, (_, i) => currentValue[i] ?? '').join(''),\n  )\n\n  let wasComplete = false\n\n  function setValue(next: string[]) {\n    const sized = Array.from({ length: effectiveLength }, (_, i) => next[i] ?? '')\n    if (value === undefined) internalValue = sized\n    value = sized\n    onValueChange?.(sized)\n    const complete = sized.length > 0 && sized.every((c) => c !== '')\n    if (complete && !wasComplete) {\n      wasComplete = true\n      const joined = sized.join('')\n      onComplete?.(joined)\n      if (autoSubmit) {\n        document.dispatchEvent(new CustomEvent('pin-submit', { detail: joined, bubbles: true }))\n      }\n    } else if (!complete) {\n      wasComplete = false\n    }\n  }\n\n  function focusSlot(index: number) {\n    const el = slotEls.get(Math.min(Math.max(index, 0), effectiveLength - 1))\n    el?.focus()\n    el?.select()\n  }\n\n  function filterChars(raw: string): string {\n    return type === 'number' ? raw.replace(/\\D/g, '') : raw.replace(/\\s/g, '')\n  }\n\n  function distribute(index: number, chars: string) {\n    if (!chars) {\n      const next = [...currentValue]\n      next[index] = ''\n      setValue(next)\n      return\n    }\n    const next = Array.from({ length: effectiveLength }, (_, i) => currentValue[i] ?? '')\n    let i = index\n    for (const ch of chars) {\n      if (i >= effectiveLength) break\n      next[i] = ch\n      i++\n    }\n    setValue(next)\n    focusSlot(Math.min(i, effectiveLength - 1))\n  }\n\n  function handleSlotInput(index: number, raw: string) {\n    if (disabled) return\n    // No maxlength on the slot: a single char sets + advances, an empty string\n    // clears, and multi-char input (paste, SMS autofill, replace-all) fans out\n    // across this slot and the ones after it.\n    const chars = filterChars(raw)\n    if (chars.length > 1) {\n      distribute(index, chars)\n      return\n    }\n    const next = [...currentValue]\n    next[index] = chars\n    setValue(next)\n    if (chars) focusSlot(index + 1)\n  }\n\n  function handleSlotKeyDown(event: KeyboardEvent, index: number) {\n    if (event.key === 'Backspace' && (currentValue[index] ?? '') === '' && index > 0) {\n      event.preventDefault()\n      const next = [...currentValue]\n      next[index - 1] = ''\n      setValue(next)\n      focusSlot(index - 1)\n    } else if (event.key === 'ArrowLeft') {\n      event.preventDefault()\n      focusSlot(index - 1)\n    } else if (event.key === 'ArrowRight') {\n      event.preventDefault()\n      focusSlot(index + 1)\n    } else if (event.key === 'Home') {\n      event.preventDefault()\n      focusSlot(0)\n    } else if (event.key === 'End') {\n      event.preventDefault()\n      focusSlot(effectiveLength - 1)\n    } else if (event.key === 'Delete') {\n      const next = [...currentValue]\n      next[index] = ''\n      setValue(next)\n    }\n  }\n\n  function handleSlotPaste(event: ClipboardEvent, index: number) {\n    if (disabled) return\n    const text = filterChars(event.clipboardData?.getData('text') ?? '')\n    if (!text) return\n    event.preventDefault()\n    distribute(index, text)\n  }\n\n  function register(index: number, el: HTMLInputElement) {\n    // Idempotent: the slot $effect re-runs when registration itself\n    // invalidates its deps, so a redundant write would ping-pong forever\n    // (effect_update_depth_exceeded). Skip the write when nothing changed.\n    if (slotEls.get(index) === el) return\n    slotEls.set(index, el)\n    slotCount = slotEls.size\n  }\n\n  function unregister(index: number) {\n    if (!slotEls.has(index)) return\n    slotEls.delete(index)\n    slotCount = slotEls.size\n  }\n\n  setContext<PinInputContext>(PIN_INPUT_CTX, {\n    get value() {\n      return currentValue\n    },\n    get placeholder() {\n      return placeholder\n    },\n    get mask() {\n      return mask\n    },\n    get status() {\n      return status\n    },\n    get size() {\n      return size\n    },\n    get type() {\n      return type\n    },\n    get otp() {\n      return otp\n    },\n    get disabled() {\n      return disabled\n    },\n    get length() {\n      return effectiveLength\n    },\n    register,\n    unregister,\n    handleSlotInput,\n    handleSlotKeyDown,\n    handleSlotPaste,\n    focusSlot,\n  })\n\n  // One-shot shake when status transitions into error (not on mount / not continuous).\n  let isShaking = $state(false)\n  // svelte-ignore state_referenced_locally -- transition tracker, deliberately seeded from the mount-time status.\n  let prevStatus = status\n\n  $effect(() => {\n    if (status === 'error' && prevStatus !== 'error') {\n      isShaking = false\n      tick().then(() => {\n        isShaking = true\n      })\n    }\n    prevStatus = status\n  })\n</script>\n\n<div\n  bind:this={ref}\n  data-uipkge\n  data-slot=\"pin-input\"\n  data-status={status === 'default' ? undefined : status}\n  role=\"group\"\n  class={cn(\n    'flex items-center gap-2 disabled:cursor-not-allowed has-disabled:opacity-50',\n    isShaking && 'pin-input-shake',\n    className,\n  )}\n  onanimationend={(e) => {\n    if (e.target === ref) isShaking = false\n  }}\n  {...restProps}\n>\n  {@render children?.()}\n  {#if name}\n    <input type=\"hidden\" {name} value={joinedValue} {disabled} />\n  {/if}\n</div>\n\n<style>\n  @keyframes pin-input-shake {\n    0%,\n    100% {\n      transform: translateX(0);\n    }\n    20% {\n      transform: translateX(-5px);\n    }\n    40% {\n      transform: translateX(5px);\n    }\n    60% {\n      transform: translateX(-3px);\n    }\n    80% {\n      transform: translateX(3px);\n    }\n  }\n\n  [data-slot='pin-input'].pin-input-shake {\n    animation: pin-input-shake 380ms cubic-bezier(0.36, 0.07, 0.19, 0.97) both;\n  }\n\n  @media (prefers-reduced-motion: reduce) {\n    [data-slot='pin-input'].pin-input-shake {\n      animation: none !important;\n    }\n  }\n</style>\n",
      "type": "registry:ui",
      "target": "~/src/lib/components/ui/pin-input/PinInput.svelte"
    },
    {
      "path": "packages/registry-svelte/components/pin-input/PinInputGroup.svelte",
      "content": "<script lang=\"ts\" module>\n  import type { HTMLAttributes } from 'svelte/elements'\n\n  export interface PinInputGroupProps extends HTMLAttributes<HTMLDivElement> {\n    ref?: HTMLDivElement | null\n  }\n</script>\n\n<script lang=\"ts\">\n  import { cn } from '$lib/utils'\n\n  let { class: className, children, ref = $bindable(null), ...restProps }: PinInputGroupProps = $props()\n</script>\n\n<div bind:this={ref} data-uipkge data-slot=\"pin-input-group\" class={cn('flex items-center', className)} {...restProps}>\n  {@render children?.()}\n</div>\n",
      "type": "registry:ui",
      "target": "~/src/lib/components/ui/pin-input/PinInputGroup.svelte"
    },
    {
      "path": "packages/registry-svelte/components/pin-input/PinInputSeparator.svelte",
      "content": "<script lang=\"ts\" module>\n  import type { Snippet } from 'svelte'\n  import type { HTMLAttributes } from 'svelte/elements'\n\n  export interface PinInputSeparatorProps extends HTMLAttributes<HTMLDivElement> {\n    children?: Snippet\n    ref?: HTMLDivElement | null\n  }\n</script>\n\n<script lang=\"ts\">\n  import { Minus } from '@lucide/svelte'\n\n  let { class: className, children, ref = $bindable(null), ...restProps }: PinInputSeparatorProps = $props()\n</script>\n\n<div bind:this={ref} data-uipkge data-slot=\"pin-input-separator\" role=\"separator\" class={className} {...restProps}>\n  {#if children}\n    {@render children()}\n  {:else}\n    <Minus aria-hidden=\"true\" />\n  {/if}\n</div>\n",
      "type": "registry:ui",
      "target": "~/src/lib/components/ui/pin-input/PinInputSeparator.svelte"
    },
    {
      "path": "packages/registry-svelte/components/pin-input/PinInputSlot.svelte",
      "content": "<script lang=\"ts\" module>\n  import type { HTMLInputAttributes } from 'svelte/elements'\n\n  export interface PinInputSlotProps extends Omit<HTMLInputAttributes, 'type' | 'value'> {\n    index: number\n    /** Override the inherited mask flag for this slot. */\n    mask?: boolean\n    ref?: HTMLInputElement | null\n  }\n</script>\n\n<script lang=\"ts\">\n  import { getContext, tick, untrack } from 'svelte'\n  import { cn } from '$lib/utils'\n  import { PIN_INPUT_CTX, type PinInputContext } from './PinInput.svelte'\n\n  let { class: className, index, mask, ref = $bindable(null), ...restProps }: PinInputSlotProps = $props()\n\n  const ctx = getContext<PinInputContext | undefined>(PIN_INPUT_CTX)\n  if (!ctx) throw new Error('PinInputSlot must be used inside <PinInput>')\n\n  const slotValue = $derived(ctx.value[index] ?? '')\n  const effectiveMask = $derived(mask ?? ctx.mask)\n  const inputType = $derived(effectiveMask ? 'password' : 'text')\n  const sizeClasses = $derived(\n    ctx.size === 'sm' ? 'h-8 w-8 text-sm' : ctx.size === 'lg' ? 'h-12 w-12 text-xl' : 'h-10 w-10 text-base',\n  )\n  const statusClasses = $derived(\n    ctx.status === 'error'\n      ? 'border-destructive focus:border-destructive focus:ring-destructive/40 text-destructive'\n      : ctx.status === 'warning'\n        ? 'border-warning focus:border-warning focus:ring-warning/40 text-warning'\n        : ctx.status === 'success'\n          ? 'border-success focus:border-success focus:ring-success/40 text-success'\n          : '',\n  )\n\n  $effect(() => {\n    // untrack: the register write must not subscribe this effect to the\n    // group's slot map (effect_update_depth_exceeded). Registration is idempotent.\n    if (ref) untrack(() => ctx.register(index, ref as HTMLInputElement))\n    return () => ctx.unregister(index)\n  })\n\n  // Quiet slot pop when a character lands.\n  let isPopping = $state(false)\n  let isFirstValue = true\n  let prevValue: string | undefined = undefined\n\n  $effect(() => {\n    const next = slotValue\n    // First run captures the mount value (empty or prefilled) without animating.\n    if (isFirstValue) {\n      isFirstValue = false\n      prevValue = next\n      return\n    }\n    if (next !== '' && next !== prevValue) {\n      // Restart the animation even on rapid sequential digits.\n      isPopping = false\n      tick().then(() => {\n        isPopping = true\n      })\n    }\n    prevValue = next\n  })\n</script>\n\n<input\n  bind:this={ref}\n  {...restProps}\n  value={slotValue}\n  type={inputType}\n  inputmode={ctx.type === 'number' ? 'numeric' : 'text'}\n  autocomplete={ctx.otp ? 'one-time-code' : undefined}\n  placeholder={ctx.placeholder || undefined}\n  disabled={ctx.disabled}\n  data-uipkge\n  data-slot=\"pin-input-slot\"\n  data-index={index}\n  aria-label={`Digit ${index + 1}`}\n  class={cn(\n    'border-input bg-background text-foreground relative -ml-px flex items-center justify-center border text-center shadow-xs outline-none first:ml-0 first:rounded-l-md last:rounded-r-md',\n    'transition-[border-color,box-shadow,color,transform] duration-150 ease-out',\n    'focus:border-ring focus:ring-ring/40 focus:relative focus:z-10 focus:ring-2',\n    'disabled:cursor-not-allowed disabled:opacity-50',\n    isPopping && 'pin-slot-pop',\n    sizeClasses,\n    statusClasses,\n    className,\n  )}\n  oninput={(e) => ctx.handleSlotInput(index, e.currentTarget.value)}\n  onkeydown={(e) => ctx.handleSlotKeyDown(e, index)}\n  onpaste={(e) => ctx.handleSlotPaste(e, index)}\n  onfocus={(e) => e.currentTarget.select()}\n  onanimationend={(e) => {\n    if (e.target === ref) isPopping = false\n  }}\n/>\n\n<style>\n  /* Quieter than payment-card char pop — whole slot, short overshoot. */\n  @keyframes pin-slot-pop {\n    0% {\n      transform: scale(1);\n    }\n    40% {\n      transform: scale(1.06);\n    }\n    100% {\n      transform: scale(1);\n    }\n  }\n\n  [data-slot='pin-input-slot'].pin-slot-pop {\n    animation: pin-slot-pop 200ms cubic-bezier(0.22, 1.25, 0.36, 1) both;\n    z-index: 1;\n  }\n\n  @media (prefers-reduced-motion: reduce) {\n    [data-slot='pin-input-slot'].pin-slot-pop {\n      animation: none !important;\n    }\n    [data-slot='pin-input-slot'] {\n      transition-duration: 0ms !important;\n    }\n  }\n</style>\n",
      "type": "registry:ui",
      "target": "~/src/lib/components/ui/pin-input/PinInputSlot.svelte"
    },
    {
      "path": "packages/registry-svelte/components/pin-input/index.ts",
      "content": "export {\n  default as PinInput,\n  type PinInputProps,\n  type PinInputStatus,\n  type PinInputSize,\n  type PinInputType,\n  type PinInputContext,\n} from './PinInput.svelte'\nexport { default as PinInputGroup, type PinInputGroupProps } from './PinInputGroup.svelte'\nexport { default as PinInputSeparator, type PinInputSeparatorProps } from './PinInputSeparator.svelte'\nexport { default as PinInputSlot, type PinInputSlotProps } from './PinInputSlot.svelte'\n",
      "type": "registry:ui",
      "target": "~/src/lib/components/ui/pin-input/index.ts"
    }
  ],
  "dependencies": [
    "@lucide/svelte"
  ],
  "devDependencies": [],
  "registryDependencies": [],
  "description": "One-time-code input — N separate boxes that auto-advance and accept paste. Use for SMS verification, 2FA, and short numeric codes. Length, masking, and per-slot status all configurable.",
  "categories": [
    "form"
  ]
}