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