OTP Field
A six-slot verification code field with paste, autofill, and verifying, error and success states.
npx shadcn@latest add https://sundaeui.dev/r/otp-field.json'use client'
import { useCallback, useEffect, useId, useRef, useState } from 'react'
import './otp-field.css'
export type OtpStatus = 'idle' | 'verifying' | 'error' | 'success' | 'expired'
export interface OtpFieldProps {
/** Number of digits. */
length?: number
/** Controlled value. Omit to let the field manage its own state. */
value?: string
onChange?: (value: string) => void
/** Fires once every slot is filled. Return a promise to show the verifying state. */
onComplete?: (code: string) => void | Promise<unknown>
status?: OtpStatus
/** Accessible label for the whole field. */
label?: string
/** Visually show a separator after this many digits (0 to disable). */
groupSize?: number
autoFocus?: boolean
disabled?: boolean
name?: string
/** Allowed characters. Digits by default. */
pattern?: RegExp
className?: string
/** id of an element that describes the field (helper/error text). */
describedBy?: string
}
const DIGITS = /^[0-9]$/
/**
* A one-time-code field rendered as individual slots, backed by a single
* native input. One input means native paste, SMS autofill
* (autocomplete="one-time-code"), undo, selection and screen-reader support
* all work without re-implementing them.
*/
export function OtpField({
length = 6,
value: controlled,
onChange,
onComplete,
status = 'idle',
label = 'Verification code',
groupSize = 3,
autoFocus,
disabled,
name = 'code',
pattern = DIGITS,
className,
describedBy,
}: OtpFieldProps) {
const [uncontrolled, setUncontrolled] = useState('')
const value = controlled ?? uncontrolled
const inputRef = useRef<HTMLInputElement>(null)
const [focused, setFocused] = useState(false)
const [caret, setCaret] = useState(0)
const [shakeKey, setShakeKey] = useState(0)
const lastCompleted = useRef<string | null>(null)
const id = useId()
const locked = disabled || status === 'verifying' || status === 'success'
const setValue = useCallback(
(next: string) => {
if (controlled === undefined) setUncontrolled(next)
onChange?.(next)
},
[controlled, onChange],
)
const sanitize = useCallback(
(raw: string) =>
Array.from(raw)
.filter((c) => pattern.test(c))
.join('')
.slice(0, length),
[length, pattern],
)
// Fire onComplete exactly once per distinct full code.
useEffect(() => {
if (value.length === length && lastCompleted.current !== value) {
lastCompleted.current = value
void onComplete?.(value)
}
if (value.length < length) lastCompleted.current = null
}, [value, length, onComplete])
// Replay the shake whenever we enter the error state.
useEffect(() => {
if (status === 'error') setShakeKey((k) => k + 1)
}, [status])
const syncCaret = () => {
const el = inputRef.current
if (!el) return
const pos = Math.min(el.selectionStart ?? value.length, length - 1)
setCaret(pos)
}
useEffect(() => {
if (status === 'error' && inputRef.current) {
// Select everything so the next keystroke replaces the wrong code.
inputRef.current.focus()
inputRef.current.setSelectionRange(0, value.length)
}
}, [status]) // eslint-disable-line react-hooks/exhaustive-deps
const activeIndex = focused ? caret : -1
return (
<div
className={['sd-otp', className].filter(Boolean).join(' ')}
data-status={status}
data-disabled={locked || undefined}
>
<input
ref={inputRef}
id={id}
name={name}
className="sd-otp__input"
value={value}
inputMode={pattern === DIGITS ? 'numeric' : 'text'}
autoComplete="one-time-code"
autoCorrect="off"
spellCheck={false}
maxLength={length}
aria-label={label}
aria-invalid={status === 'error' || status === 'expired' || undefined}
aria-describedby={describedBy}
aria-busy={status === 'verifying' || undefined}
autoFocus={autoFocus}
disabled={disabled}
readOnly={status === 'verifying' || status === 'success'}
onFocus={() => {
setFocused(true)
requestAnimationFrame(syncCaret)
}}
onBlur={() => setFocused(false)}
onPointerUp={(e) => {
// The input sits over the slots; map the pointer to the slot beneath it.
if (locked) return
const slots = e.currentTarget.parentElement?.querySelectorAll<HTMLElement>('[data-slot]')
if (!slots) return
let i = value.length
slots.forEach((el, idx) => {
const r = el.getBoundingClientRect()
if (e.clientX >= r.left - 4 && e.clientX <= r.right + 4) i = idx
})
i = Math.min(i, value.length)
e.currentTarget.setSelectionRange(i, Math.min(i + 1, value.length))
setCaret(Math.min(i, length - 1))
}}
onSelect={syncCaret}
onKeyUp={syncCaret}
onChange={(e) => {
const next = sanitize(e.target.value)
setValue(next)
requestAnimationFrame(syncCaret)
}}
onPaste={(e) => {
const text = sanitize(e.clipboardData.getData('text'))
if (!text) return
e.preventDefault()
setValue(text)
requestAnimationFrame(() => {
inputRef.current?.setSelectionRange(text.length, text.length)
syncCaret()
})
}}
/>
<div className="sd-otp__slots" aria-hidden="true" key={`slots-${shakeKey}`}>
{Array.from({ length }, (_, i) => {
const char = value[i]
const isActive = i === activeIndex && !locked
return (
<span key={i} className="sd-otp__cell">
{groupSize > 0 && i > 0 && i % groupSize === 0 && <span className="sd-otp__sep" />}
<span
className="sd-otp__slot"
data-slot={i}
data-filled={char ? '' : undefined}
data-active={isActive ? '' : undefined}
style={{ '--i': i } as React.CSSProperties}
>
{char ? (
<span className="sd-otp__char" key={char + i}>
{char}
</span>
) : (
isActive && <span className="sd-otp__caret" />
)}
</span>
</span>
)
})}
</div>
</div>
)
}/* OtpField — Sundae S—001
Theme by overriding the custom properties on .sd-otp or any ancestor. */
.sd-otp {
--otp-size: 52px;
--otp-height: 60px;
--otp-gap: 8px;
--otp-radius: 12px;
--otp-font: ui-monospace, 'SF Mono', 'Geist Mono', Menlo, monospace;
--otp-bg: #fdfcfa;
--otp-fg: #17140f;
--otp-muted: #b3ab9f;
--otp-border: #e2ddd3;
--otp-border-hover: #cfc7ba;
--otp-accent: #d9472b;
--otp-active: #17140f;
--otp-ring: color-mix(in oklab, var(--otp-active) 10%, transparent);
--otp-error: #b42318;
--otp-success: #2c7a4b;
--otp-ease: cubic-bezier(0.22, 1, 0.36, 1);
position: relative;
display: inline-flex;
font-family: var(--otp-font);
-webkit-tap-highlight-color: transparent;
}
:where(.dark) .sd-otp {
--otp-bg: #151412;
--otp-fg: #eeeae3;
--otp-muted: #514b44;
--otp-border: #2e2b27;
--otp-border-hover: #423d37;
--otp-accent: #f0603f;
--otp-active: #eeeae3;
--otp-ring: color-mix(in oklab, var(--otp-active) 14%, transparent);
--otp-error: #f2705f;
--otp-success: #5cb883;
}
/* The real input covers the slots, invisible, so taps, long-press paste
and SMS autofill hit a native control. */
.sd-otp__input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
border: 0;
padding: 0;
margin: 0;
font-size: 16px; /* prevents iOS zoom */
color: transparent;
caret-color: transparent;
background: transparent;
z-index: 1;
cursor: text;
letter-spacing: -1em;
}
.sd-otp__input:disabled,
.sd-otp[data-disabled] .sd-otp__input {
cursor: default;
}
.sd-otp__slots {
display: flex;
align-items: center;
gap: var(--otp-gap);
}
.sd-otp__cell {
display: contents;
}
.sd-otp__sep {
width: 10px;
height: 2px;
border-radius: 2px;
background: var(--otp-border-hover);
margin-inline: 2px;
}
.sd-otp__slot {
position: relative;
display: grid;
place-items: center;
width: var(--otp-size);
height: var(--otp-height);
border-radius: var(--otp-radius);
background: var(--otp-bg);
color: var(--otp-fg);
font-size: 24px;
font-weight: 500;
font-variant-numeric: tabular-nums;
box-shadow:
inset 0 0 0 1px var(--otp-border),
0 1px 2px rgb(0 0 0 / 0.04);
transition:
box-shadow 200ms var(--otp-ease),
transform 240ms var(--otp-ease),
background-color 200ms var(--otp-ease),
color 200ms var(--otp-ease);
}
.sd-otp[data-status='idle']:hover .sd-otp__slot:not([data-active]) {
box-shadow:
inset 0 0 0 1px var(--otp-border-hover),
0 1px 2px rgb(0 0 0 / 0.04);
}
/* A short inner baseline hints where the next digit lands. */
.sd-otp__slot::after {
content: '';
position: absolute;
left: 50%;
bottom: 12px;
width: 12px;
height: 2px;
border-radius: 2px;
background: var(--otp-muted);
transform: translateX(-50%) scaleX(1);
opacity: 0.55;
transition:
transform 240ms var(--otp-ease),
opacity 200ms var(--otp-ease);
}
.sd-otp__slot[data-filled]::after,
.sd-otp__slot[data-active]::after {
transform: translateX(-50%) scaleX(0);
opacity: 0;
}
.sd-otp__slot[data-active] {
box-shadow:
inset 0 0 0 1.5px var(--otp-active),
0 0 0 4px var(--otp-ring);
transform: translateY(-1px);
}
.sd-otp__char {
display: block;
animation: sd-otp-in 320ms var(--otp-ease) both;
}
@keyframes sd-otp-in {
from {
opacity: 0;
transform: translateY(6px) scale(0.86);
filter: blur(2px);
}
}
.sd-otp__caret {
width: 1.5px;
height: 26px;
border-radius: 1px;
background: var(--otp-accent);
animation: sd-otp-caret 1.1s steps(1, end) infinite;
}
@keyframes sd-otp-caret {
50% {
opacity: 0;
}
}
/* Verifying: a soft highlight travels across the slots. */
.sd-otp[data-status='verifying'] .sd-otp__slot {
color: color-mix(in oklab, var(--otp-fg) 55%, transparent);
animation: sd-otp-wave 1.2s var(--otp-ease) infinite;
animation-delay: calc(var(--i) * 90ms);
}
@keyframes sd-otp-wave {
0%,
100% {
transform: none;
}
30% {
transform: translateY(-3px);
box-shadow:
inset 0 0 0 1px var(--otp-border-hover),
0 6px 14px -6px rgb(0 0 0 / 0.18);
}
}
/* Error: one decisive shake, then hold the red edge. */
.sd-otp[data-status='error'] .sd-otp__slots {
animation: sd-otp-shake 420ms var(--otp-ease);
}
.sd-otp[data-status='error'] .sd-otp__slot,
.sd-otp[data-status='expired'] .sd-otp__slot {
box-shadow:
inset 0 0 0 1.5px var(--otp-error),
0 0 0 3px color-mix(in oklab, var(--otp-error) 10%, transparent);
}
.sd-otp[data-status='expired'] .sd-otp__slot {
color: var(--otp-muted);
}
@keyframes sd-otp-shake {
15% {
transform: translateX(-6px);
}
35% {
transform: translateX(5px);
}
55% {
transform: translateX(-3px);
}
75% {
transform: translateX(2px);
}
}
/* Success: slots settle to green one after another. */
.sd-otp[data-status='success'] .sd-otp__slot {
color: var(--otp-success);
animation: sd-otp-ok 520ms var(--otp-ease) both;
animation-delay: calc(var(--i) * 45ms);
}
@keyframes sd-otp-ok {
40% {
transform: translateY(-4px) scale(1.04);
}
100% {
transform: none;
box-shadow:
inset 0 0 0 1.5px var(--otp-success),
0 0 0 3px color-mix(in oklab, var(--otp-success) 12%, transparent);
background: color-mix(in oklab, var(--otp-success) 7%, var(--otp-bg));
}
}
@media (max-width: 420px) {
.sd-otp {
--otp-size: 44px;
--otp-height: 54px;
--otp-gap: 6px;
}
.sd-otp__sep {
width: 6px;
}
}
@media (prefers-reduced-motion: reduce) {
.sd-otp *,
.sd-otp *::after {
animation-duration: 1ms !important;
animation-iteration-count: 1 !important;
transition-duration: 1ms !important;
}
.sd-otp__caret {
animation: none;
}
}Overview
Every slot is drawn individually, but there is only one native input underneath. That single input is why paste, SMS autofill on iOS and Android, undo, selection and screen readers all behave as people expect. Nothing is re-implemented.
Digits settle into their slots with a short blur-in. The active slot lifts with an accent ring and a blinking caret. Verifying sends a soft wave across the row, an error shakes once and holds a red edge, and success turns the slots green one after another.
Use it for email or SMS verification, 2FA, and confirming sensitive actions. Sundae’s own sign-up flow uses this component.
When to use
- Collecting input where the control itself should guide people.
When not to use
- Cases a plain native input already serves well.
Installation
pnpm dlx shadcn@latest add https://sundaeui.dev/r/otp-field.jsonnpx shadcn@latest add https://sundaeui.dev/r/otp-field.jsonyarn dlx shadcn@latest add https://sundaeui.dev/r/otp-field.jsonbunx --bun shadcn@latest add https://sundaeui.dev/r/otp-field.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy otp-field.tsx and otp-field.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { OtpField, type OtpStatus } from '@/components/sundae/otp-field'
export function VerifyEmail() {
const [status, setStatus] = useState<OtpStatus>('idle')
return (
<OtpField
autoFocus
status={status}
onComplete={async (code) => {
setStatus('verifying')
const ok = await verifyCode(code)
setStatus(ok ? 'success' : 'error')
}}
/>
)
}Props
| Prop | Type | Default |
|---|---|---|
lengthNumber of slots. | number | 6 |
valueControlled value. Omit for uncontrolled use. | string | — |
onChangeCalled with the sanitised value on every change. | (value: string) => void | — |
onCompleteCalled once each time every slot is filled with a new code. | (code: string) => void | Promise<unknown> | — |
statusVisual and ARIA state. While verifying or after success, the field is read-only. | 'idle' | 'verifying' | 'error' | 'success' | 'expired' | 'idle' |
labelAccessible name of the input. | string | 'Verification code' |
groupSizeDraws a separator after this many slots. Pass 0 to disable. | number | 3 |
patternSingle-character allow-list. Non-digit patterns switch the mobile keyboard to text. | RegExp | /^[0-9]$/ |
describedByid of helper or error text, wired to aria-describedby. | string | — |
autoFocusFocus on mount. | boolean | — |
disabledDisables the field. | boolean | — |
nameForm field name, so the field works inside a native <form>. | string | 'code' |
Accessibility
- Built on a single native <input> with autocomplete="one-time-code", so assistive technology reads it as one text field, not six.
- Sets aria-invalid in the error and expired states and aria-busy while verifying.
- After an error, the whole code is selected so the next keystroke starts over.
- With prefers-reduced-motion, every animation finishes instantly and the caret stops blinking.
Customization
- Every colour, size and the radius is a CSS custom property on .sd-otp (--otp-accent, --otp-size, --otp-radius…).
- Adding a .dark class to any ancestor switches to the dark palette.
- Pass pattern={/^[A-Z0-9]$/i} for alphanumeric codes.
FAQ
- Is OTP Field free?
- Yes. OTP Field is free for personal and commercial projects, with full source on this page.
- How do I install OTP Field?
- Run npx shadcn@latest add https://sundaeui.dev/r/otp-field.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does OTP Field support keyboard and screen readers?
- Built on a single native <input> with autocomplete="one-time-code", so assistive technology reads it as one text field, not six. Sets aria-invalid in the error and expired states and aria-busy while verifying.
- Does OTP Field respect reduced motion?
- Yes. With prefers-reduced-motion set, decorative motion is removed and state changes are instant.
More like this
All forms →- Pro
Drop Zone
Forms · Refined Date Picker
Forms · UnderstatedCombobox
Forms · UnderstatedRange Slider
Forms · Understated- Pro
Onboarding Flow
Forms · Refined - Pro
Wheel Picker
Forms · Refined