diff --git a/apps/blocks/src/blocks/account/account-phone-numbers/account-phone-numbers.test.tsx b/apps/blocks/src/blocks/account/account-phone-numbers/account-phone-numbers.test.tsx
index b46f7c89..290ad832 100644
--- a/apps/blocks/src/blocks/account/account-phone-numbers/account-phone-numbers.test.tsx
+++ b/apps/blocks/src/blocks/account/account-phone-numbers/account-phone-numbers.test.tsx
@@ -40,7 +40,7 @@ async function renderBlock(adapter: AccountPhoneNumbersAdapter, props: Partial
screen.getByText(display).closest('li') as HTMLElement;
-const codeBox = (index: number) => screen.getByLabelText(`Digit ${index} of 6`);
+const codeField = () => screen.getByLabelText('Verification code');
describe('AccountPhoneNumbers', () => {
it('lists numbers with their status on one line', async () => {
@@ -79,13 +79,13 @@ describe('AccountPhoneNumbers', () => {
expect(adapter.sendCode).toHaveBeenCalledWith({ id: 'new-0', number: '+12025550143' });
expect(await screen.findByText('Enter the 6-digit code sent to +1 202 555 0143.')).toBeInTheDocument();
- await user.click(codeBox(1));
+ await user.click(codeField());
await user.paste('000000');
await user.click(screen.getByRole('button', { name: 'Verify' }));
expect(await screen.findByRole('alert')).toHaveTextContent('That code didn’t work.');
- expect(codeBox(1)).toHaveAttribute('aria-invalid', 'true');
+ expect(codeField()).toHaveAttribute('aria-invalid', 'true');
- await user.click(codeBox(1));
+ await user.click(codeField());
await user.paste('123456');
await user.click(screen.getByRole('button', { name: 'Verify' }));
await waitFor(() => expect(within(rowFor('+1 202 555 0143')).getByText('Verified')).toBeInTheDocument());
diff --git a/packages/ui/registry.json b/packages/ui/registry.json
index 8c527cd3..5d02a634 100644
--- a/packages/ui/registry.json
+++ b/packages/ui/registry.json
@@ -2632,13 +2632,17 @@
"name": "input-otp",
"type": "registry:ui",
"title": "Input OTP",
- "description": "A one-time-code input with one box per digit that handles typing, Backspace, arrows, paste, and OS autofill as one value.",
+ "description": "A one-time-code input that draws one slot per digit over a single field, so typing, selection, paste, and OS autofill behave like text, with a gliding focus ring and digit motion.",
"categories": [
"input"
],
"docs": "Import: `import { InputOtp } from '@/components/ui/input-otp'`",
+ "dependencies": [
+ "motion"
+ ],
"registryDependencies": [
- "cn"
+ "cn",
+ "fluid-highlight"
],
"files": [
{
diff --git a/packages/ui/src/components/input-otp.tsx b/packages/ui/src/components/input-otp.tsx
index d53af76b..bf97d9e7 100644
--- a/packages/ui/src/components/input-otp.tsx
+++ b/packages/ui/src/components/input-otp.tsx
@@ -1,36 +1,37 @@
'use client';
-import { useId, useRef, useState, type ClipboardEvent, type KeyboardEvent, type Ref } from 'react';
+import { useEffect, useRef, useState, type ChangeEvent, type ClipboardEvent, type FocusEvent, type Ref } from 'react';
+import { motion, useAnimate, useReducedMotion, type Transition } from 'motion/react';
+import { FluidHighlight } from '../lib/motion/fluid-highlight';
import { cn } from '../lib/utils';
-// Adapted from BoardUI Input OTP (MIT), https://www.boardui.com/r/input-otp.json.
-
/**
- * One-time-code input: one box per digit, side by side.
+ * One-time-code input: one slot per digit, side by side.
*
- * It looks like N inputs but behaves like one value, and the two views stay in step in every direction:
+ * The slots are drawn, not typed into. A single transparent `` lies over the whole row and owns the value,
+ * the caret, and the selection; the slots mirror it. That one-field model is what makes the code behave like text:
*
- * type fills a box and advances
- * Backspace clears the box, or steps back when the box is already empty
- * arrows move between boxes without changing anything
- * paste distributes across the boxes from wherever the caret is
- * autofill the OS hands the whole code to the first box at once
+ * type fills the selected slot and moves on
+ * Backspace deletes like any text field; later digits close the gap
+ * ⌘A / Shift selects across slots, so cut, copy, and paste work on the whole code or a slice of it
+ * paste strips separators and inserts at the caret; a complete code replaces the value outright
+ * autofill the OS hands the whole code to the one field, which is all `autoComplete="one-time-code"` needs
*
- * That last one is why every box carries `autoComplete="one-time-code"` and why the change handler accepts more
- * than a single character: iOS and Chrome both deliver the full code into whichever box has focus.
+ * It also means one tab stop and one labelled field for assistive tech, rather than six "digit N" boxes.
*
- * Digits are monospace so the boxes stay optically even; in a proportional face a `1` is visibly narrower than
- * an `8`, which makes a row of fixed boxes look mis-set even when it is perfectly aligned.
+ * Because the real glyphs and caret are transparent, the slots can animate what the user sees: digits rise into
+ * place, a drawn caret blinks in the empty active slot, the focus ring glides between slots, and the row shakes
+ * once when the code turns invalid. All of it stands still under reduced motion.
*/
interface InputOtpProps {
- /** Number of digit boxes. */
+ /** Number of digit slots. */
length?: number;
- /** Controlled value. Longer strings are truncated to `length`. */
+ /** Controlled value. Non-digits are dropped and longer strings are truncated to `length`. */
value?: string;
defaultValue?: string;
onChange?: (value: string) => void;
- /** Fires once the last box is filled. */
+ /** Fires when an edit leaves every slot filled with a code that differs from the previous one. */
onComplete?: (value: string) => void;
isDisabled?: boolean;
isInvalid?: boolean;
@@ -44,9 +45,21 @@ interface InputOtpProps {
ref?: Ref;
}
+interface SlotSelection {
+ start: number;
+ end: number;
+}
+
+type SelectionDirection = 'forward' | 'backward' | 'none';
+
const DIGITS_ONLY = /\D/g;
-// Preserve empty interior slots when editing a controlled code.
-const INVALID_SLOT = /[^0-9 ]/g;
+// Chrome restores autofill styling and the caret position a beat after a value change, so the mirror re-reads them.
+const RESYNC_DELAYS = [0, 10, 50];
+// A hard on/off blink, like a text caret, rather than a fade.
+const CARET_BLINK = { opacity: [1, 1, 0, 0] };
+const CARET_TIMING: Transition = { duration: 1, times: [0, 0.5, 0.5, 1], ease: 'linear', repeat: Infinity };
+
+const toCode = (raw: string, length: number) => raw.replace(DIGITS_ONLY, '').slice(0, length);
function InputOtp({
length = 6,
@@ -63,135 +76,246 @@ function InputOtp({
className,
ref,
}: InputOtpProps) {
- const groupId = useId();
- const inputsRef = useRef>([]);
- const [internal, setInternal] = useState(defaultValue.replace(DIGITS_ONLY, '').slice(0, length));
+ const inputRef = useRef(null);
+ const previousSelection = useRef<[number | null, number | null, SelectionDirection]>([null, null, 'none']);
+ const wasInvalid = useRef(isInvalid);
+ const [rowRef, animate] = useAnimate();
+ const reduceMotion = useReducedMotion();
+ const [internal, setInternal] = useState(() => toCode(defaultValue, length));
+ const [isFocused, setIsFocused] = useState(false);
+ const [selection, setSelection] = useState(null);
const controlled = value !== undefined;
- const code = (controlled ? value : internal).replace(INVALID_SLOT, '').slice(0, length);
-
- const commit = (next: string) => {
- const clean = next.replace(INVALID_SLOT, '').slice(0, length);
- if (!controlled) setInternal(clean);
- onChange?.(clean);
- if (clean.length === length && !clean.includes(' ')) onComplete?.(clean);
- };
+ const code = toCode(controlled ? value : internal, length);
- const focusBox = (index: number) => {
- const target = inputsRef.current[Math.max(0, Math.min(index, length - 1))];
- target?.focus();
- target?.select();
- };
+ const mirror = (start: number, end: number) =>
+ setSelection((current) => (current?.start === start && current.end === end ? current : { start, end }));
- const clearAt = (index: number) => {
- const chars = code.padEnd(length, ' ').split('');
- chars[index] = ' ';
- commit(chars.join('').trimEnd());
+ const commit = (next: string) => {
+ if (next === code) return;
+ if (!controlled) setInternal(next);
+ onChange?.(next);
+ // Comparing against the previous code keeps a re-render or an identical paste from verifying twice, while
+ // correcting one digit of a full code still counts as a new attempt.
+ if (next.length === length) onComplete?.(next);
};
- /** Writes `digits` starting at `index`, which covers typing and autofill. */
- const writeFrom = (index: number, digits: string) => {
- const clean = digits.replace(DIGITS_ONLY, '');
- if (clean === '') {
- clearAt(index);
- return;
- }
- // Autofill or a complete pasted code replaces the whole value, even when a middle slot has focus.
- const start = clean.length >= length ? 0 : index;
- const chars = code.padEnd(length, ' ').split('');
- for (let offset = 0; offset < clean.length && start + offset < length; offset += 1) {
- chars[start + offset] = clean[offset];
- }
- commit(chars.join('').trimEnd());
- focusBox(start + clean.length);
- };
+ useEffect(() => {
+ const input = inputRef.current;
+ if (!input) return;
- const onKeyDown = (event: KeyboardEvent, index: number) => {
- if (event.key === 'Backspace') {
- event.preventDefault();
- // Clear in place; the caret only steps back on an already-empty box, which makes holding Backspace feel right.
- if (code[index]?.trim()) {
- clearAt(index);
+ // The native caret only ever sits between characters. Over a slot, that reads as nothing being selected, so
+ // a collapsed caret inside a full run is widened to select the digit after it (or before it, when moving left).
+ const syncSelection = () => {
+ if (document.activeElement !== input) {
+ setSelection(null);
return;
}
- clearAt(Math.max(0, index - 1));
- focusBox(index - 1);
- } else if (event.key === 'ArrowLeft') {
- event.preventDefault();
- focusBox(index - 1);
- } else if (event.key === 'ArrowRight') {
- event.preventDefault();
- focusBox(index + 1);
- }
+ const { selectionStart, selectionEnd, selectionDirection, maxLength, value: current } = input;
+ const [, previousEnd] = previousSelection.current;
+ let start = -1;
+ let end = -1;
+ let direction: SelectionDirection | undefined;
+
+ if (current.length !== 0 && selectionStart !== null && selectionEnd !== null) {
+ const isCaret = selectionStart === selectionEnd;
+ const isAppending = selectionStart === current.length && current.length < maxLength;
+ if (isCaret && !isAppending) {
+ const caret = selectionStart;
+ if (caret === 0) {
+ [start, end, direction] = [0, 1, 'forward'];
+ } else if (caret === maxLength) {
+ [start, end, direction] = [caret - 1, caret, 'backward'];
+ } else if (maxLength > 1 && current.length > 1) {
+ let offset = 0;
+ const [previousStart] = previousSelection.current;
+ if (previousStart !== null && previousEnd !== null) {
+ direction = caret < previousEnd ? 'backward' : 'forward';
+ const wasAppending = previousStart === previousEnd && previousStart < maxLength;
+ if (direction === 'backward' && !wasAppending) offset = -1;
+ }
+ start = caret + offset;
+ end = caret + offset + 1;
+ }
+ }
+ if (start !== -1 && end !== -1 && start !== end) input.setSelectionRange(start, end, direction);
+ }
+
+ const nextStart = start !== -1 ? start : (selectionStart ?? 0);
+ const nextEnd = end !== -1 ? end : (selectionEnd ?? 0);
+ mirror(nextStart, nextEnd);
+ previousSelection.current = [nextStart, nextEnd, direction ?? selectionDirection ?? 'none'];
+ };
+
+ document.addEventListener('selectionchange', syncSelection, { capture: true });
+ syncSelection();
+ if (document.activeElement === input) setIsFocused(true);
+ return () => document.removeEventListener('selectionchange', syncSelection, { capture: true });
+ }, []);
+
+ useEffect(() => {
+ const timeouts = RESYNC_DELAYS.map((delay) =>
+ setTimeout(() => {
+ const input = inputRef.current;
+ if (!input) return;
+ // A non-bubbling `input` event clears Chrome's `:autofill` state without reaching React's onChange.
+ input.dispatchEvent(new Event('input'));
+ if (document.activeElement !== input || input.selectionStart === null || input.selectionEnd === null) return;
+ mirror(input.selectionStart, input.selectionEnd);
+ previousSelection.current = [input.selectionStart, input.selectionEnd, input.selectionDirection ?? 'none'];
+ }, delay),
+ );
+ return () => timeouts.forEach(clearTimeout);
+ }, [code, isFocused]);
+
+ useEffect(() => {
+ const turnedInvalid = isInvalid && !wasInvalid.current;
+ wasInvalid.current = isInvalid;
+ if (!turnedInvalid || reduceMotion || !rowRef.current) return;
+ animate(rowRef.current, { x: [0, -6, 6, -4, 4, -2, 0] }, { duration: 0.4, ease: 'easeInOut' });
+ }, [isInvalid, reduceMotion, animate, rowRef]);
+
+ const onInputChange = (event: ChangeEvent) => {
+ const next = toCode(event.target.value, length);
+ // Deleting doesn't fire `selectionchange`, so the mirror is nudged by hand.
+ if (next.length < code.length) document.dispatchEvent(new Event('selectionchange'));
+ commit(next);
};
- const onPaste = (event: ClipboardEvent, index: number) => {
+ const onPaste = (event: ClipboardEvent) => {
+ // Always handled here: a native paste of "123 456" would be cut to `maxLength` before the spaces were dropped,
+ // and iOS pastes over the whole field rather than at the caret.
event.preventDefault();
- writeFrom(index, event.clipboardData.getData('text'));
+ const input = event.currentTarget;
+ const pasted = event.clipboardData.getData('text/plain').replace(DIGITS_ONLY, '');
+ if (pasted === '') return;
+ const start = input.selectionStart ?? code.length;
+ const end = input.selectionEnd ?? start;
+ // A complete code replaces the value even when the caret is mid-way; it's an autofill or a copied message.
+ const next =
+ pasted.length >= length ? pasted.slice(0, length) : (code.slice(0, start) + pasted + code.slice(end)).slice(0, length);
+ input.value = next;
+ commit(next);
+ const caret = Math.min(next.length, length - 1);
+ input.setSelectionRange(caret, next.length);
+ mirror(caret, next.length);
};
+ const onFocus = (event: FocusEvent) => {
+ // Focus lands on the first empty slot, or selects the last digit of a full code so typing replaces it.
+ const input = event.currentTarget;
+ const start = Math.min(input.value.length, length - 1);
+ input.setSelectionRange(start, input.value.length);
+ mirror(start, input.value.length);
+ setIsFocused(true);
+ };
+
+ const isSlotActive = (index: number) =>
+ isFocused &&
+ selection !== null &&
+ (selection.start === selection.end ? index === selection.start : index >= selection.start && index < selection.end);
+ const isRange = selection !== null && selection.end - selection.start > 1;
+
return (
, which breaks React's next update; a code is never translatable.
+ translate="no"
+ className={cn('w-full', size === 'sm' ? 'max-w-[15.5rem]' : 'max-w-[21rem]', className)}
>
- {Array.from({ length }, (_, index) => {
- const filled = code[index]?.trim() ?? '';
- const gapBefore = groupEvery !== undefined && index > 0 && index % groupEvery === 0;
- return (
-
-
{
- inputsRef.current[index] = node;
- }}
- id={`${groupId}-${index}`}
- // `text` with a numeric mode rather than `number`: a number input brings spinners, accepts `e`
- // and `-`, and reports an empty value for anything it considers malformed.
- type="text"
- inputMode="numeric"
- autoComplete="one-time-code"
- // Long enough to accept a full autofilled code in one box.
- maxLength={length}
- disabled={isDisabled}
- aria-label={`Digit ${index + 1} of ${length}`}
- aria-invalid={isInvalid || undefined}
+
+ {Array.from({ length }, (_, index) => {
+ const digit = code[index];
+ const isActive = isSlotActive(index);
+ const gapBefore = groupEvery !== undefined && index > 0 && index % groupEvery === 0;
+ return (
+
writeFrom(index, event.target.value)}
- onKeyDown={(event) => onKeyDown(event, index)}
- onPaste={(event) => onPaste(event, index)}
- onFocus={(event) => event.target.select()}
+ data-active={isActive || undefined}
+ // The single active slot is what the gliding ring tracks; a range is shown by the slots themselves.
+ data-highlighted={(isActive && !isRange) || undefined}
className={cn(
- 'w-full min-w-0 text-center font-mono tabular-nums',
- size === 'sm' ? 'h-9 rounded-md text-base' : 'h-11 rounded-lg text-lg sm:h-12',
+ 'relative flex min-w-0 flex-1 items-center justify-center overflow-hidden font-mono tabular-nums',
+ size === 'sm' ? 'h-9 max-w-9 rounded-md text-base' : 'h-11 max-w-10 rounded-lg text-lg sm:h-12 sm:max-w-12',
+ gapBefore && (size === 'sm' ? 'ml-1.5' : 'ml-1 sm:ml-2'),
'border border-input bg-background text-foreground shadow-xs dark:bg-input/32',
- 'outline-none transition-[background-color,border-color,box-shadow] duration-150 ease-out motion-reduce:transition-none',
- 'hover:border-ring/50 focus-visible:border-ring/60 focus-visible:ring-[3px] focus-visible:ring-ring/35',
+ 'transition-[background-color,border-color] duration-150 ease-out motion-reduce:transition-none',
+ !isDisabled && 'group-hover/otp:border-ring/50',
+ isActive && isRange && 'bg-accent',
// Keep the invalid state visible without relying on tint alone.
- isInvalid && 'border-destructive bg-destructive/5 text-destructive hover:border-destructive',
- isDisabled && 'cursor-not-allowed bg-muted text-muted-foreground shadow-none',
+ isInvalid && 'border-destructive bg-destructive/5 text-destructive group-hover/otp:border-destructive',
+ isDisabled && 'bg-muted text-muted-foreground shadow-none',
+ )}
+ >
+ {digit !== undefined && (
+ // Keyed on the digit so each new one replays the rise, including a correction in place.
+
+ {digit}
+
+ )}
+ {isActive && digit === undefined && (
+ // The real caret is transparent, so the empty active slot draws its own.
+
)}
- />
-
- );
- })}
+
+ );
+ })}
+
+
setIsFocused(false)}
+ className={cn(
+ // Present and hit-testable over the whole row, but invisible: transparent ink rather than opacity,
+ // because iOS only offers paste on a press-and-hold over a visible field.
+ 'absolute inset-0 z-20 size-full cursor-text appearance-none border-0 bg-transparent p-0 font-mono leading-none',
+ 'text-transparent caret-transparent tracking-[-0.5em] tabular-nums shadow-none outline-none',
+ // A font as tall as the row keeps every glyph under the slots, so a tap anywhere lands in the field.
+ size === 'sm' ? 'text-[36px]' : 'text-[44px] sm:text-[48px]',
+ 'selection:bg-transparent selection:text-transparent',
+ 'autofill:opacity-0! autofill:shadow-none! autofill:[-webkit-text-fill-color:transparent]!',
+ // iOS lays the glyphs out wider and leaks the caret at the edge; squeeze them back under the slots.
+ 'supports-[-webkit-touch-callout:none]:-left-px supports-[-webkit-touch-callout:none]:right-px',
+ 'supports-[-webkit-touch-callout:none]:font-thin supports-[-webkit-touch-callout:none]:tracking-[-0.6em]',
+ 'disabled:cursor-not-allowed',
+ )}
+ />
+
);
}
diff --git a/packages/ui/test/input-otp.test.tsx b/packages/ui/test/input-otp.test.tsx
index 30b4ea61..d5e99d78 100644
--- a/packages/ui/test/input-otp.test.tsx
+++ b/packages/ui/test/input-otp.test.tsx
@@ -33,7 +33,8 @@ function Harness({ onComplete }: { onComplete?: (value: string) => void }) {
);
}
-const box = (index: number) => container!.querySelector(`[aria-label="Digit ${index} of 6"]`)!;
+const field = () => container!.querySelector('input')!;
+const slots = () => Array.from(container!.querySelectorAll('[data-slot="input-otp-slot"]'));
const value = () => container!.querySelector('[data-testid="value"]')!.textContent;
async function type(input: HTMLInputElement, text: string) {
@@ -50,47 +51,61 @@ async function paste(input: HTMLInputElement, text: string) {
await act(async () => input.dispatchEvent(event));
}
-async function press(input: HTMLInputElement, key: string) {
- await act(async () => input.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })));
-}
-
describe('InputOtp', () => {
- it('fills a box and advances focus as digits are typed', async () => {
+ it('mirrors the single field into one slot per digit', async () => {
await render();
- await type(box(1), '4');
- expect(value()).toBe('4');
- expect(document.activeElement).toBe(box(2));
+ await type(field(), '42');
+ expect(value()).toBe('42');
+ expect(slots().map((slot) => slot.textContent)).toEqual(['4', '2', '', '', '', '']);
});
- it('ignores non-digits', async () => {
+ it('drops non-digits', async () => {
await render();
- await type(box(1), 'a');
- expect(value()).toBe('');
+ await type(field(), '4a');
+ expect(value()).toBe('4');
});
- it('distributes a pasted or autofilled code and reports completion', async () => {
+ it('strips separators from a pasted code and reports completion', async () => {
const onComplete = vi.fn();
await render();
- await paste(box(3), '123 456');
+ await act(async () => field().setSelectionRange(0, 0));
+ await paste(field(), '123 456');
expect(value()).toBe('123456');
expect(onComplete).toHaveBeenCalledWith('123456');
});
- it('clears in place, then steps back on an empty box', async () => {
+ it('lets a complete code replace the value wherever the caret is', async () => {
+ await render();
+ await type(field(), '99');
+ await act(async () => field().setSelectionRange(1, 1));
+ await paste(field(), '123456');
+ expect(value()).toBe('123456');
+ });
+
+ it('completes again only when a full code changes', async () => {
+ const onComplete = vi.fn();
+ await render();
+ await type(field(), '123456');
+ await paste(field(), '123456');
+ expect(onComplete).toHaveBeenCalledTimes(1);
+ await type(field(), '123457');
+ expect(onComplete).toHaveBeenLastCalledWith('123457');
+ expect(onComplete).toHaveBeenCalledTimes(2);
+ });
+
+ it('highlights the slot the caret lands on when focused', async () => {
await render();
- await paste(box(1), '12');
- await press(box(2), 'Backspace');
- expect(value()).toBe('1');
- await press(box(2), 'Backspace');
- expect(value()).toBe('');
- expect(document.activeElement).toBe(box(1));
+ await type(field(), '12');
+ await act(async () => field().focus());
+ expect(slots()[2].hasAttribute('data-highlighted')).toBe(true);
+ expect(slots().filter((slot) => slot.hasAttribute('data-highlighted'))).toHaveLength(1);
});
- it('marks every box invalid without putting aria-invalid on the group', async () => {
+ it('exposes one labelled field and marks it invalid', async () => {
await render();
- const group = container!.querySelector('[role="group"]')!;
- expect(group.getAttribute('aria-label')).toBe('Verification code');
- expect(group.hasAttribute('aria-invalid')).toBe(false);
- expect(box(1).getAttribute('aria-invalid')).toBe('true');
+ expect(container!.querySelectorAll('input')).toHaveLength(1);
+ expect(field().getAttribute('aria-label')).toBe('Verification code');
+ expect(field().getAttribute('aria-invalid')).toBe('true');
+ expect(slots().every((slot) => slot.getAttribute('aria-hidden') === 'true')).toBe(true);
});
});