Input OTP
One-time-code field shown as a row of character boxes, backed by a single hidden input with SMS autofill, pattern filtering and paste support.
Preview
Check your phone, Ines
We sent a sign-in code to the number ending 4821.
Didn’t arrive? Resend in 0:42
Verified
That code has expired.
Locked until the locker is assigned.
Install
Add the editable source with the shadcn CLI. Your project also needs the shared theme tokens this component uses. Setup and requirements. The Pebble installer is not published yet.
npx shadcn@latest add https://pebbleui.com/r/input-otp.jsonProps
| Prop | Type | Default | Description |
|---|---|---|---|
| maxLength | number | — | Number of characters and slots (required). |
| value | string | — | Controlled value. |
| defaultValue | string | "" | Uncontrolled initial value. |
| onChange | (value: string) => void | — | Called with the filtered value on every change. |
| onComplete | (value: string) => void | — | Called once when the value reaches maxLength. |
| pattern | string | OTP_PATTERN_DIGITS | RegExp source each typed or pasted character must match. |
| disabled | boolean | false | Disables the field. |
| containerClassName | string | — | Classes for the wrapper holding the slots. |
| index (InputOTPSlot) | number | — | Which character the slot shows. |
Build notes
The root renders a `relative` flex wrapper with the caller's slot children,
then a real `<input>` positioned `absolute inset-0` over them at `opacity-0`
(transparent text, caret and selection). It is `z-20`, above the active slot's
`z-10` ring, and the slots are `pointer-events-none`, so any tap or long-press
on a box lands in the input (native paste callout and autofill included); the
wrapper's `onClick` also focuses it as a fallback. State is `value` (controlled or internal), plus a `focused` flag from
focus/blur. Every change runs `commit()`: filter characters with
`new RegExp(pattern)` tested per character, slice to `maxLength`, skip if
unchanged, then call `onChange` and, if the length equals `maxLength`,
`onComplete`. Paste is intercepted (`preventDefault`) so the native
`maxLength` cannot truncate before filtering (`12-34-56` would otherwise lose
digits); a paste over a full selection replaces the value instead of
appending. On focus the selection always moves to the end and a
`focusPending` ref is set; a 50 ms timer clears it (the browser's own focus selection is reported shortly after focus in a queued `selectionchange` task, well inside that unless the main thread is blocked for longer), and so do the first keydown, pointerdown or contextmenu and blur. A select-all with no key or pointer event (touch callout, menu-bar Select All) after that window is kept. On every `select` event the selection moves to the end
(guarded to avoid a select loop) unless it spans the whole value and
`focusPending` is clear, so a browser select-all that arrives after the focus
event is still collapsed. The active slot index is just
`min(value.length, maxLength - 1)` while focused. A `selectedValue` state records the value a full selection was seen on (from each `select` event, cleared on focus and blur); the filled slots are `data-selected` and the fake caret hidden only while it equals the current value, so a paste, autofill or controlled update drops the fill; a render whose value differs from it clears it, so returning to that value later does not restore the fill. The root builds a `slots`
array (`char`, `isActive`, `isSelected`, `hasFakeCaret`) and provides it through React
context; `InputOTPSlot` reads `slots[index]`. The fake caret is a 1px x 16px
`bg-foreground` line animated with the `otp-caret-blink` keyframes (opacity
1 -> 0 -> 1, `steps(1,end)`, 1 s, infinite), which ship in the registry item's
`css`. No `window`/`document` access, so it renders on the server.
**Pebble (2026-11-03).** The joined segmented boxes became separate recessed wells: each slot is `rounded-md bg-muted` with `ring-1 ring-border-control` (field edges stay at full strength) and `inset-shadow-[0_1px_2px_var(--hairline)]`, spaced `gap-1.5` inside a group and `gap-2.5` around the separator, which fades to `text-muted-foreground/70`. A filled slot lifts to `bg-card` (`data-filled`), so progress reads at a glance; the active slot is `bg-card` with the loud `ring-2 ring-ring`. The root reads `aria-invalid` from its props and shares `invalid` through context; slots set `data-invalid` and `ring-danger`, which also overrides the active ring. Digits are 15 px `font-medium tabular-nums`. The character renders in a `motion.span` (`data-slot="input-otp-char"`) keyed by the character, with `initial` `{opacity 0, y 6, blur 2px}` and the shared spring; a `typed` flag set on the first committed change keeps characters from a default or SSR value still (`initial={false}`), and there is no exit animation, so the slot's text updates synchronously on Backspace. The selection fill also sets `ring-accent` so the well edge matches.
<!-- PEBBLE-LICENSE -->View source
registry/input/input-otp/input-otp.tsx
"use client";
/* PEBBLE-LICENSE
Built by Pebble. Component: input-otp.
First-party license: MIT
The following first-party terms apply only to original Pebble material.
MIT License
Copyright (c) 2026 Praxys Solutions LLC (Pebble)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
THIRD-PARTY NOTICES
These notices apply to identified upstream portions; Pebble terms do not replace or narrow their rights.
Upstream: https://raw.githubusercontent.com/shadcn-ui/ui/main/LICENSE.md
MIT License
Copyright (c) 2023 shadcn
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
END PEBBLE-LICENSE */
import * as React from "react";
import { MotionConfig, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
/** Digits only. Each typed or pasted character is tested against `pattern`. */
export const OTP_PATTERN_DIGITS = "^[0-9]+$";
/** Letters and digits. */
export const OTP_PATTERN_ALPHANUMERIC = "^[A-Za-z0-9]+$";
type SlotState = {
char: string | null;
isActive: boolean;
isSelected: boolean;
hasFakeCaret: boolean;
};
type InputOTPContextValue = { slots: SlotState[]; invalid: boolean; typed: boolean };
const InputOTPContext = React.createContext<InputOTPContextValue>({
slots: [],
invalid: false,
typed: false,
});
export type InputOTPProps = Omit<
React.ComponentProps<"input">,
"value" | "defaultValue" | "onChange" | "maxLength" | "pattern" | "children"
> & {
/** Number of characters (and slots). */
maxLength: number;
value?: string;
defaultValue?: string;
onChange?: (value: string) => void;
/** Called once each time the value reaches `maxLength` characters. */
onComplete?: (value: string) => void;
/** RegExp source each character must match. Defaults to digits only. */
pattern?: string;
/** Classes for the wrapper that holds the slots. */
containerClassName?: string;
children?: React.ReactNode;
};
function filterChars(text: string, regex: RegExp) {
return Array.from(text)
.filter((ch) => regex.test(ch))
.join("");
}
export function InputOTP({
maxLength,
value: valueProp,
defaultValue = "",
onChange,
onComplete,
pattern = OTP_PATTERN_DIGITS,
containerClassName,
className,
children,
disabled,
ref,
onFocus,
onBlur,
onSelect,
onPaste,
onKeyDown,
onPointerDown,
onContextMenu,
autoComplete = "one-time-code",
inputMode,
...props
}: InputOTPProps) {
const regex = React.useMemo(() => new RegExp(pattern), [pattern]);
// A digit keypad fits any pattern that takes digits and rejects letters.
const keyboard =
inputMode ?? (regex.test("0") && !regex.test("a") && !regex.test("A") ? "numeric" : "text");
const [uncontrolled, setUncontrolled] = React.useState(() =>
filterChars(defaultValue, regex).slice(0, maxLength),
);
const isControlled = valueProp !== undefined;
const value = (isControlled ? valueProp : uncontrolled).slice(0, maxLength);
const [focused, setFocused] = React.useState(false);
const [typed, setTyped] = React.useState(false);
// The value a full selection was last seen on; any other value means the
// selection is gone (a paste, autofill or controlled update moved the caret).
const [selectedValue, setSelectedValue] = React.useState<string | null>(null);
const inputRef = React.useRef<HTMLInputElement | null>(null);
// True briefly after focus. Browsers apply their Tab-focus select-all (or
// restore an old selection) shortly after the focus event, reported in a
// queued selectionchange task, so it arrives as a select while this is set
// and is collapsed rather than kept.
// It ends on a timer, so a later select-all with no key or pointer event
// (a touch callout, a menu-bar Select All) is kept, or earlier on the
// user's first key, pointer or context-menu gesture.
const focusPending = React.useRef(false);
const focusTimer = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const endFocusPending = () => {
focusPending.current = false;
clearTimeout(focusTimer.current);
};
React.useEffect(() => () => clearTimeout(focusTimer.current), []);
const setRefs = React.useCallback(
(node: HTMLInputElement | null) => {
inputRef.current = node;
if (typeof ref === "function") ref(node);
else if (ref) ref.current = node;
},
[ref],
);
const commit = (next: string) => {
const clean = filterChars(next, regex).slice(0, maxLength);
if (clean === value) return;
// Characters animate in only after the user has typed, never on first paint.
if (!typed) setTyped(true);
if (!isControlled) setUncontrolled(clean);
onChange?.(clean);
if (clean.length === maxLength) onComplete?.(clean);
};
const isAllSelected = (input: HTMLInputElement) =>
input.value.length > 0 &&
input.selectionStart === 0 &&
input.selectionEnd === input.value.length;
// The caret always sits after the last character, so the active slot is
// simply the next empty one. A select-all the user makes (keys, context
// menu, long-press callout) is kept and shown on every slot, so the next
// keystroke or paste replaces the whole code; the one browsers make on focus
// is collapsed, so typing into a partly filled field appends.
const moveCaretToEnd = (input: HTMLInputElement, keepAll = true) => {
const end = input.value.length;
if (keepAll && isAllSelected(input)) return;
if (input.selectionStart !== end || input.selectionEnd !== end) {
input.setSelectionRange(end, end);
}
};
const activeIndex = focused ? Math.min(value.length, maxLength - 1) : -1;
// Once the value moves off the selected one, forget it, so a later return
// to the same string does not bring back a fill the caret no longer has.
if (selectedValue !== null && selectedValue !== value) setSelectedValue(null);
const showSelection = focused && value.length > 0 && selectedValue === value;
const slots: SlotState[] = Array.from({ length: maxLength }, (_, i) => ({
char: value[i] ?? null,
isActive: i === activeIndex,
isSelected: showSelection && i < value.length,
hasFakeCaret: i === activeIndex && value.length < maxLength && !showSelection,
}));
const invalidProp = props["aria-invalid"];
const invalid = invalidProp === true || invalidProp === "true";
return (
<InputOTPContext.Provider value={{ slots, invalid, typed }}>
<div
data-slot="input-otp-container"
className={cn(
"relative flex min-w-0 items-center gap-2.5 has-disabled:opacity-50",
containerClassName,
)}
onClick={() => {
if (!disabled) inputRef.current?.focus();
}}
>
<MotionConfig reducedMotion="user">{children}</MotionConfig>
<input
ref={setRefs}
data-slot="input-otp"
type="text"
autoComplete={autoComplete}
inputMode={keyboard}
spellCheck={false}
autoCorrect="off"
autoCapitalize="off"
maxLength={maxLength}
disabled={disabled}
value={value}
className={cn(
// Visually hidden but focusable and clickable: it covers the slots
// and stacks above the raised active one, so a tap anywhere on them
// lands in the field.
"absolute inset-0 z-20 size-full cursor-text bg-transparent text-transparent caret-transparent opacity-0 outline-none selection:bg-transparent disabled:cursor-not-allowed",
className,
)}
onChange={(e) => commit(e.target.value)}
onPaste={(e) => {
onPaste?.(e);
if (e.defaultPrevented) return;
e.preventDefault();
const pasted = filterChars(e.clipboardData.getData("text"), regex);
if (!pasted) return;
const replace = pasted.length >= maxLength || isAllSelected(e.currentTarget);
commit(replace ? pasted : value + pasted);
}}
onKeyDown={(e) => {
endFocusPending();
onKeyDown?.(e);
}}
onPointerDown={(e) => {
endFocusPending();
onPointerDown?.(e);
}}
onContextMenu={(e) => {
endFocusPending();
onContextMenu?.(e);
}}
onFocus={(e) => {
endFocusPending();
focusPending.current = true;
focusTimer.current = setTimeout(endFocusPending, 50);
setFocused(true);
moveCaretToEnd(e.currentTarget, false);
setSelectedValue(null);
onFocus?.(e);
}}
onBlur={(e) => {
endFocusPending();
setFocused(false);
setSelectedValue(null);
onBlur?.(e);
}}
onSelect={(e) => {
moveCaretToEnd(e.currentTarget, !focusPending.current);
setSelectedValue(isAllSelected(e.currentTarget) ? e.currentTarget.value : null);
onSelect?.(e);
}}
{...props}
/>
</div>
</InputOTPContext.Provider>
);
}
export function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-otp-group"
className={cn("flex min-w-0 items-center gap-1.5", className)}
{...props}
/>
);
}
export type InputOTPSlotProps = React.ComponentProps<"div"> & { index: number };
const charSpring = { type: "spring", stiffness: 420, damping: 32, mass: 0.8 } as const;
export function InputOTPSlot({ index, className, ...props }: InputOTPSlotProps) {
const { slots, invalid, typed } = React.useContext(InputOTPContext);
const reduce = useReducedMotion();
const slot = slots[index];
const isActive = slot?.isActive ?? false;
const char = slot?.char ?? null;
return (
<div
data-slot="input-otp-slot"
data-active={isActive}
data-selected={slot?.isSelected ?? false}
data-filled={char !== null}
data-invalid={invalid || undefined}
aria-hidden="true"
className={cn(
// Pebble: each slot is a recessed well (muted, inner shade, full-strength
// control ring). A filled slot turns card-white; the active one gets the
// loud focus ring; an invalid code rings every slot in danger.
"pointer-events-none relative flex size-9 min-w-0 items-center justify-center rounded-md bg-muted text-[15px] font-medium text-foreground tabular-nums ring-1 inset-shadow-[0_1px_2px_var(--hairline)] ring-border-control",
"transition-[background-color,box-shadow] duration-(--motion-fast) ease-enter motion-reduce:transition-none",
"data-[filled=true]:bg-card",
"data-[invalid=true]:ring-danger data-[invalid=true]:data-[active=true]:ring-danger",
"data-[active=true]:z-10 data-[active=true]:bg-card data-[active=true]:ring-2 data-[active=true]:ring-ring",
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[selected=true]:ring-accent",
// Invalid outranks selected: a wrong code stays red even while highlighted.
"data-[invalid=true]:data-[selected=true]:ring-danger",
"forced-colors:border forced-colors:border-[CanvasText] forced-colors:data-[active=true]:border-[Highlight] forced-colors:data-[invalid=true]:border-2 forced-colors:data-[invalid=true]:border-dashed",
"forced-colors:data-[selected=true]:bg-[Highlight] forced-colors:data-[selected=true]:text-[HighlightText]",
"forced-colors:data-[active=true]:outline-2 forced-colors:data-[active=true]:outline-[Highlight]",
className,
)}
{...props}
>
{char !== null && (
// Keyed by character, so a new or changed character rises into place.
// Characters already there on first paint render still.
<motion.span
key={char}
data-slot="input-otp-char"
initial={
typed ? (reduce ? { opacity: 0 } : { opacity: 0, y: 6, filter: "blur(2px)" }) : false
}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
transition={charSpring}
>
{char}
</motion.span>
)}
{slot?.hasFakeCaret && (
<span
data-slot="input-otp-caret"
className="pointer-events-none absolute inset-0 flex items-center justify-center"
>
<span
data-slot="input-otp-caret-line"
className="h-4 w-px bg-foreground motion-safe:animate-[otp-caret-blink_1s_steps(1,end)_infinite]"
/>
</span>
)}
</div>
);
}
export function InputOTPSeparator({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-otp-separator"
role="separator"
className={cn("flex shrink-0 items-center text-muted-foreground/70", className)}
{...props}
>
<svg
aria-hidden="true"
viewBox="0 0 16 16"
className="size-4"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
>
<path d="M3.5 8h9" />
</svg>
</div>
);
}
