Skip to content
UI Kit
Accent color

One-time code

Separate slots on one real input: paste, SMS autofill and backspace work as people expect.

One of 6 components in Special input fields

2 variants

Variants

Verify

Six digits in two groups, inside a sign-in card. Each digit pops into its slot, a wrong code shakes, the right one sends a green wave.

States

Empty, wrong and accepted, plus a short PIN.

Details

What One-time code brings along, read from the registry and the source.

Variants
2
Version
1.0.0 · since 1.0.0
File
components/ui/otp-input.tsx
Brings along
utilskitinputicon
npm packages
None
Languages
English and German, 2 texts of its own
Studio
Adjust in code

Props

The OtpInputProps interface from components/ui/otp-input.tsx, with the comments from the code.

  • lengthoptional
    number
  • groupsoptional
    number[]

    Split into groups, e.g. [3, 3] shows 123 · 456

  • valueoptional
    string
  • defaultValueoptional
    string
  • onValueChangeoptional
    (value: string) => void
  • onCompleteoptional
    (value: string) => void

    Called once all slots are filled

  • modeoptional
    'numeric' | 'alphanumeric'

    numeric (default) or alphanumeric codes

  • labeloptional
    React.ReactNode
  • hintoptional
    React.ReactNode
  • erroroptional
    React.ReactNode
  • successoptional
    boolean

    Marks the code as accepted

  • disabledoptional
    boolean
  • nameoptional
    string
  • sizeoptional
    'md' | 'lg'
  • autoFocusoptional
    boolean

Built-in texts

One-time code writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 2 texts
labelEnglish One-time codeGerman Einmalcode
digitsEnglish digitsGerman Ziffern

Accessibility

Read from the source of the component.

  • Sets aria-describedby, aria-invalid, aria-label and aria-live, so screen readers announce purpose and state.
  • Announces changes through a live region without moving focus.
  • Every field has a label, visible or for screen readers.
  • Adds text for screen readers where icons and graphics stand alone.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still. 1 places in the code switch motion off then.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 201 lines
components/ui/otp-input.tsx
'use client';

import { useEffect, useId, useRef, useState } from 'react';
import { cn, prefersReducedMotion, useTexts } from '@/lib/kit';
import { FieldError, FieldHint } from '@/components/ui/input';
import { Icon } from '@/components/ui/icon';

const TEXTS = {
  en: { label: 'One-time code', digits: 'digits' },
  de: { label: 'Einmalcode', digits: 'Ziffern' },
};

export interface OtpInputProps {
  length?: number;
  /** Split into groups, e.g. [3, 3] shows 123 · 456 */
  groups?: number[];
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  /** Called once all slots are filled */
  onComplete?: (value: string) => void;
  /** numeric (default) or alphanumeric codes */
  mode?: 'numeric' | 'alphanumeric';
  label?: React.ReactNode;
  hint?: React.ReactNode;
  error?: React.ReactNode;
  /** Marks the code as accepted */
  success?: boolean;
  disabled?: boolean;
  name?: string;
  size?: 'md' | 'lg';
  autoFocus?: boolean;
  labels?: Partial<(typeof TEXTS)['en']>;
  className?: string;
}

const EASE = 'cubic-bezier(0.2,0.7,0.2,1)';
const moves = () => !prefersReducedMotion() && typeof Element !== 'undefined' && typeof Element.prototype.animate === 'function';

/** Slot motion: a pop for each new character, one shake for a wrong code, a green wave for an accepted one */
function useSlotMotion(row: React.RefObject<HTMLDivElement | null>, value: string, error: boolean, success: boolean) {
  const slots = () => Array.from(row.current?.querySelectorAll<HTMLElement>('[data-slot]') ?? []);
  const wave = () => {
    if (!moves()) return;
    slots().forEach((el, i) =>
      el.animate(
        [
          { transform: 'translateY(0) scale(1)' },
          { transform: 'translateY(-5px) scale(1.06)', boxShadow: '0 0 0 3px color-mix(in oklab, var(--success) 22%, transparent)', offset: 0.45 },
          { transform: 'translateY(0) scale(1)' },
        ],
        { duration: 520, delay: i * 55, easing: EASE },
      ),
    );
  };
  const prev = useRef({ value, error, success });
  useEffect(() => {
    const p = prev.current;
    prev.current = { value, error, success };
    if (!moves()) return;
    if (value.length > p.value.length) {
      const list = slots();
      for (let i = p.value.length; i < value.length; i++)
        list[i]?.animate(
          [
            { transform: 'scale(0.82)', opacity: 0.4 },
            { transform: 'scale(1.08)', opacity: 1, offset: 0.6 },
            { transform: 'scale(1)', opacity: 1 },
          ],
          { duration: 300, delay: (i - p.value.length) * 35, easing: EASE },
        );
    }
    if (error && !p.error) row.current?.animate([{ transform: 'translateX(0)' }, { transform: 'translateX(-7px)' }, { transform: 'translateX(6px)' }, { transform: 'translateX(-4px)' }, { transform: 'translateX(2px)' }, { transform: 'translateX(0)' }], { duration: 440, easing: 'ease-out' });
    if (success && !p.success) wave();
  }, [value, error, success]);
  // Accepted from the start: the wave runs once when the code scrolls into view
  useEffect(() => {
    const el = row.current;
    if (!success || !el || typeof IntersectionObserver === 'undefined') return;
    const io = new IntersectionObserver(([e]) => {
      if (!e.isIntersecting) return;
      io.disconnect();
      wave();
    });
    io.observe(el);
    return () => io.disconnect();
  }, []);
}

/**
 * A one-time code in separate slots, built on one real input. Paste, autofill
 * from SMS (autocomplete="one-time-code"), backspace and arrow keys all work natively.
 *   <OtpInput length={6} groups={[3, 3]} onComplete={verify} />
 */
export function OtpInput({ length = 6, groups, value: valueProp, defaultValue, onValueChange, onComplete, mode = 'numeric', label, hint, error, success, disabled, name, size = 'lg', autoFocus, labels, className }: OtpInputProps) {
  const t = useTexts(TEXTS, labels);
  const id = useId();
  const inputRef = useRef<HTMLInputElement>(null);
  const [inner, setInner] = useState((defaultValue ?? '').slice(0, length));
  const value = valueProp ?? inner;
  const [focused, setFocused] = useState(false);
  const [caret, setCaret] = useState(value.length);
  const pattern = mode === 'numeric' ? /[^0-9]/g : /[^0-9a-zA-Z]/g;
  const active = Math.min(caret, length - 1);
  const rowRef = useRef<HTMLDivElement>(null);
  useSlotMotion(rowRef, value, Boolean(error), Boolean(success));

  const sizes = groups ?? [length];
  const slots: number[][] = [];
  let k = 0;
  for (const g of sizes) {
    slots.push(Array.from({ length: g }, () => k++));
  }

  const syncCaret = () => requestAnimationFrame(() => setCaret(inputRef.current?.selectionStart ?? value.length));
  const describedBy = error ? `${id}-err` : hint ? `${id}-hint` : undefined;

  return (
    <div className={cn('grid w-full justify-items-center gap-3', className)}>
      <label htmlFor={`${id}-input`} className={cn('text-sm font-medium text-foreground', !label && 'sr-only')}>
        {label ?? t.label}
      </label>
      {/* Slots keep their size where there is room and shrink together in a narrow column, instead of spilling out of it */}
      <div className="relative max-w-full" onClick={() => inputRef.current?.focus()}>
        <div ref={rowRef} aria-hidden className="flex items-center gap-1.5 sm:gap-2">
          {slots.map((group, gi) => (
            <div key={gi} className="flex min-w-0 items-center gap-1.5 sm:gap-2">
              {gi > 0 && <span className="h-0.5 w-2.5 shrink-0 rounded-full bg-border-strong sm:mx-0.5 sm:w-3" />}
              {group.map((i) => {
                const char = value[i];
                const isActive = focused && i === active && (value.length < length || caret < length);
                return (
                  <span
                    key={i}
                    data-slot
                    className={cn(
                      'relative grid place-items-center rounded-lg border bg-background font-semibold tabular-nums text-foreground shadow-[var(--shadow-sm)] transition-[border-color,box-shadow,background-color] duration-300 ease-[var(--ease-kit)]',
                      'min-w-0',
                      // A fixed width that may shrink: the row then has its real width even inside a shrink-to-fit column
                      size === 'lg' ? 'h-12 w-9 shrink text-xl sm:h-14 sm:w-11 sm:text-2xl' : 'h-11 w-9 shrink text-lg sm:w-10',
                      'border-input',
                      isActive && 'border-[color-mix(in_oklab,var(--primary)_60%,var(--border-strong))] shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent),var(--shadow-sm)]',
                      error && 'border-[color-mix(in_oklab,var(--destructive)_70%,var(--border))]',
                      success && 'border-[color-mix(in_oklab,var(--success)_60%,var(--border))] bg-[color-mix(in_oklab,var(--success)_6%,var(--background))]',
                      disabled && 'bg-muted text-muted-foreground shadow-none',
                    )}
                  >
                    {char ?? ''}
                    {isActive && !char && <span className="absolute h-6 w-px animate-[kit-pulse_1s_steps(2)_infinite] bg-foreground motion-reduce:animate-none" />}
                  </span>
                );
              })}
            </div>
          ))}
        </div>
        <input
          ref={inputRef}
          id={`${id}-input`}
          name={name}
          value={value}
          disabled={disabled}
          autoFocus={autoFocus}
          inputMode={mode === 'numeric' ? 'numeric' : 'text'}
          autoComplete="one-time-code"
          autoCapitalize="characters"
          spellCheck={false}
          maxLength={length}
          pattern={mode === 'numeric' ? `[0-9]{${length}}` : undefined}
          aria-invalid={error ? true : undefined}
          aria-describedby={describedBy}
          aria-label={typeof label === 'string' ? `${label}, ${length} ${t.digits}` : undefined}
          onFocus={() => {
            setFocused(true);
            syncCaret();
          }}
          onBlur={() => setFocused(false)}
          onKeyUp={syncCaret}
          onSelect={syncCaret}
          onChange={(e) => {
            const next = e.target.value.replace(pattern, '').slice(0, length).toUpperCase();
            setInner(next);
            onValueChange?.(next);
            syncCaret();
            if (next.length === length) onComplete?.(next);
          }}
          className="absolute inset-0 w-full cursor-text bg-transparent text-transparent caret-transparent opacity-0 outline-none selection:bg-transparent disabled:cursor-not-allowed"
        />
      </div>
      {error ? (
        <FieldError id={`${id}-err`}>{error}</FieldError>
      ) : success ? (
        <p className="flex items-center gap-1.5 text-[13px] font-medium text-success-foreground" aria-live="polite">
          <Icon name="check-circle" size={15} />
          {hint}
        </p>
      ) : hint ? (
        <FieldHint id={`${id}-hint`}>{hint}</FieldHint>
      ) : null}
    </div>
  );
}

Frequent questions

How do I install One-time code?

With aiw add @ai-website/otp-input. The command puts components/ui/otp-input.tsx into your project, the code is yours from then on. It brings along input and icon. It needs no further npm packages.

Which variants does One-time code have?

2 variants, Verify and States.

Can I translate the texts of One-time code?

Yes. One-time code carries 2 texts of its own in English and German, such as “One-time code” and “digits”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with One-time code?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find One-time code with aiw_components and install it with aiw_add @ai-website/otp-input.

Often searched

Jump to

Copied to the clipboard