Skip to content
Star on GitHub
Forms and inputsOTP field
Style
Accent
Sound

Built by DesignMe, free to use under the MIT licence.

Expert design and dev support for startups and enterprises.

Book a call

OTP field

A one-time code, one box per digit, that fills from a paste. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.

$npx designme add otp-field
Check your email

Enter the 6-digit code we sent to [email protected]

Resend code in 0:30

01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add otp-field

React

$npx designme add otp-field --react

The CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro.

02

About

What it is for and what it does.

OTP field takes a one-time code with one box per digit. Typing moves to the next box, Backspace moves back, and pasting the whole code fills every box at once. The code is checked as soon as the last box is filled: a wrong one shakes red and says how many tries are left, and the right one turns green.

  • When people confirm an email address or a phone number with a code you send them.
  • When a sign-in or a risky action needs a second step, like a code from an authenticator app.
  • When people are likely to paste the code from an email, or have a phone offer it from a message.
  • When a wrong code should be easy to fix without clearing the boxes by hand.
03

Usage

Import it and drive it from your own state.

Import

import { OtpField } from '@/components/designme/otp-field/OtpField';

Example

The demo above as a React component, 48 lines you can copy.

'use client';
// The animated preview from designme.dev, rebuilt from the React component: an example of driving it from your own state.
import { useEffect, useRef, useState } from 'react';
import { OtpField, type OtpStatus } from './OtpField';
import { useStory, useWait } from '../lib/useScript';

/* ---------- OTP field: paste a code with two digits swapped, see it fail, and type the right one ---------- */
const CODE = '482157';

export default function OtpFieldDemo() {
  const ref = useRef<HTMLDivElement>(null);
  const wait = useWait();
  const [code, setCode] = useState('');
  const [status, setStatus] = useState<OtpStatus>('idle');
  const [tries, setTries] = useState(3);
  const [left, setLeft] = useState(30);

  // the resend link waits thirty seconds
  useEffect(() => {
    if (left <= 0 || status === 'success') return;
    const t = setTimeout(() => setLeft(l => l - 1), 1000);
    return () => clearTimeout(t);
  }, [left, status]);

  // the code is checked as soon as the last box is filled
  const check = async (c: string) => {
    setStatus('checking'); await wait(900);
    if (c === CODE) return setStatus('success');
    setTries(t => t - 1); setStatus('error');
  };

  // the preview's story: a paste with two digits swapped, then the right code typed a digit at a time
  useStory(ref, async ({ wait }) => {
    await wait(2000); setCode('482175');
    await wait(3500);
    for (let i = 1; i <= 6; i++) { setStatus('idle'); setCode(CODE.slice(0, i)); await wait(500); }
  });

  return (
    <div ref={ref} style={{ width: '100%' }}>
      <OtpField description="Enter the 6-digit code we sent to [email protected]" value={code}
        onValueChange={v => { setCode(v); if (status === 'error') setStatus('idle'); }} onComplete={c => void check(c)} status={status}
        message={status === 'error' ? `That code doesn't match. ${tries ? `${tries} ${tries === 1 ? 'try' : 'tries'} left` : 'Send a new code to try again'}` : status === 'success' ? 'Email verified' : undefined}
        footer={left > 0 ? <>Resend code in <span className="dm-num">0:{String(left).padStart(2, '0')}</span></> : <button type="button" onClick={() => setLeft(30)}>Resend code</button>} />
    </div>
  );
}
04

Props

Everything OtpField takes.

PropTypeWhat it does
titlestringDefaults to "Check your email"
descriptionReactNodeLike "Enter the 6-digit code we sent to [email protected]"
iconIconName | nullThe tile above the title. Defaults to mail; null hides it
lengthnumberHow many digits. Defaults to 6, split in two halves
valuestringThe digits typed so far
defaultValuestring
onValueChange(value: string) => void
onComplete(code: string) => voidCalled with the code once every box is filled
statusOtpStatus`checking` dims the digits and spins, `error` shakes them red, `success` turns them green and locks them. Typing after an error starts the code over, so set it back to `idle` when the value changes
messagestringThe line under the boxes, like "That code doesn't match. 2 tries left". Defaults to a line for each status
footerReactNodeUnder the message, like a resend link
classNamestring
05

Code

The full source, as the CLI installs it.

OtpField.tsxReact · 108 lines
'use client';
import { useEffect, useRef, useState, type ClipboardEvent, type KeyboardEvent, type ReactNode } from 'react';
import { cx } from '../lib/cx';
import { useControllable } from '../lib/hooks';
import { Icon, type IconName } from '../lib/icons';
import { Spin } from '../lib/motion';
import '../designme.css';
import './otp-field.css';

export type OtpStatus = 'idle' | 'checking' | 'error' | 'success';

export interface OtpFieldProps {
  /** Defaults to "Check your email" */
  title?: string;
  /** Like "Enter the 6-digit code we sent to [email protected]" */
  description?: ReactNode;
  /** The tile above the title. Defaults to mail; null hides it */
  icon?: IconName | null;
  /** How many digits. Defaults to 6, split in two halves */
  length?: number;
  /** The digits typed so far */
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  /** Called with the code once every box is filled */
  onComplete?: (code: string) => void;
  /** `checking` dims the digits and spins, `error` shakes them red, `success` turns them green and locks them.
      Typing after an error starts the code over, so set it back to `idle` when the value changes */
  status?: OtpStatus;
  /** The line under the boxes, like "That code doesn't match. 2 tries left". Defaults to a line for each status */
  message?: string;
  /** Under the message, like a resend link */
  footer?: ReactNode;
  className?: string;
}

const LINE: Record<OtpStatus, string> = { idle: '', checking: 'Checking the code', error: "That code doesn't match", success: 'Verified' };

/** A one-time code with one box per digit. Typing moves along, Backspace clears and moves back, the arrow keys move,
    and a paste fills every box. The first box asks for autocomplete="one-time-code", so a phone can offer the code. */
export function OtpField({ title = 'Check your email', description, icon = 'mail', length = 6, value, defaultValue = '', onValueChange, onComplete, status = 'idle', message, footer, className }: OtpFieldProps) {
  const [code, setCode] = useControllable(value, defaultValue, onValueChange);
  const ins = useRef<(HTMLInputElement | null)[]>([]);
  const [popped, setPopped] = useState(-1);
  const locked = status === 'checking' || status === 'success';
  const digits = Array.from({ length }, (_, i) => code[i] ?? '');
  const half = Math.ceil(length / 2);

  // the code is handed over the moment the last box is filled
  const last = useRef('');
  useEffect(() => {
    if (code.length === length && code !== last.current && status === 'idle') onComplete?.(code);
    last.current = code;
  }, [code, length, status, onComplete]);
  useEffect(() => { if (popped < 0) return; const t = setTimeout(() => setPopped(-1), 160); return () => clearTimeout(t); }, [popped]);

  const focus = (i: number) => ins.current[Math.max(0, Math.min(length - 1, i))]?.focus();
  // a digit goes in at i; after a wrong code, typing starts the code over from the first box
  const put = (i: number, d: string) => {
    const start = status === 'error' ? '' : code;
    const at = status === 'error' ? 0 : Math.min(i, start.length);
    const next = (start.slice(0, at) + d + start.slice(at + 1)).slice(0, length);
    setCode(next); setPopped(at);
    if (at + 1 < length) focus(at + 1);
  };
  const fill = (text: string) => {
    const d = text.replace(/\D/g, '').slice(0, length); if (!d) return;
    setCode(d); focus(d.length < length ? d.length : length - 1);
  };
  const onKey = (i: number) => (e: KeyboardEvent<HTMLInputElement>) => {
    if (locked) return;
    if (/^\d$/.test(e.key)) { e.preventDefault(); put(i, e.key); }
    else if (e.key === 'Backspace') {
      e.preventDefault();
      if (status === 'error') { setCode(''); focus(0); }
      else if (digits[i]) setCode(code.slice(0, i) + code.slice(i + 1));
      else if (i > 0) { setCode(code.slice(0, i - 1) + code.slice(i)); focus(i - 1); }
    }
    else if (e.key === 'ArrowLeft') { e.preventDefault(); focus(i - 1); }
    else if (e.key === 'ArrowRight') { e.preventDefault(); focus(i + 1); }
  };
  const line = message ?? LINE[status];

  return (
    <div data-dm="" className={cx('dm-otp dm-panel', className)}>
      {icon && <span className="dm-otp-ic"><Icon name={icon} /></span>}
      <b className="dm-otp-t">{title}</b>
      {description && <p className="dm-muted dm-otp-s">{description}</p>}
      <div className={cx('dm-otp-in', status === 'checking' && 'dm-vf', status === 'error' && 'dm-bad', status === 'success' && 'dm-ok')} role="group" aria-label="Verification code">
        {digits.map((d, i) => (
          <span key={i} style={{ display: 'contents' }}>
            {i === half && <span className="dm-sep" aria-hidden="true" />}
            <input ref={el => { ins.current[i] = el; }} className={cx('dm-otp-d', popped === i && 'dm-bump')} value={d} readOnly={locked}
              inputMode="numeric" pattern="[0-9]*" maxLength={1} autoComplete={i ? 'off' : 'one-time-code'} aria-label={`Digit ${i + 1} of ${length}`}
              aria-invalid={status === 'error' || undefined} onKeyDown={onKey(i)} onFocus={e => e.target.select()}
              onChange={e => { const v = e.target.value.replace(/\D/g, ''); if (v.length > 1) fill(v); else if (v) put(i, v); }}
              onPaste={(e: ClipboardEvent<HTMLInputElement>) => { e.preventDefault(); if (!locked) fill(e.clipboardData.getData('text')); }} />
          </span>
        ))}
      </div>
      <p className={cx('dm-otp-m', status === 'error' && 'dm-bad', status === 'success' && 'dm-ok')} aria-live="polite">
        {status === 'checking' ? <Spin /> : status === 'error' ? <Icon name="alert" size="xs" /> : status === 'success' ? <Icon name="ok" size="xs" /> : null}{line}
      </p>
      {footer && status !== 'success' && <p className="dm-muted dm-otp-f">{footer}</p>}
    </div>
  );
}
otp-field.cssReact · 28 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- OTP field ---------- */
/* a one-time code, one box per digit: typing moves along, Backspace moves back, and a paste fills every box. A wrong
   code shakes and turns red; typing again starts it over */
.dm-otp{position:relative;width:100%;max-width:360px;margin:0 auto;padding:22px var(--dm-s-5) 18px;display:flex;flex-direction:column;align-items:center;gap:6px;text-align:center}
.dm-otp-ic{width:40px;height:40px;border-radius:var(--dm-r-in);background:var(--dm-chip);color:var(--dm-fg2);display:grid;place-items:center;margin-bottom:6px}
.dm-otp-t{font-weight:500;font-size:14px}
.dm-otp-s{margin:0 0 10px;font-size:13px;line-height:1.45;max-width:260px}
.dm-otp-in{display:flex;align-items:center;justify-content:center;gap:6px;width:100%;max-width:286px}
.dm-otp-in .dm-sep{flex:none;width:8px;height:1.5px;border-radius:1px;background:var(--dm-rail);margin:0 2px}
.dm-otp-d{flex:1 1 0;min-width:0;max-width:40px;height:var(--dm-field-h);padding:0;border:0;outline:0;border-radius:var(--dm-r-field);background:var(--dm-surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-field-line) 50%,transparent);text-align:center;font:inherit;font-size:14px;font-weight:500;font-variant-numeric:tabular-nums;color:var(--dm-fg);caret-color:var(--dm-accent-mark,var(--dm-accent));transition:box-shadow .25s,background .25s,color .25s,transform .25s var(--dm-ease)}
@supports (corner-shape:squircle){.dm-otp-d{corner-shape:squircle;border-radius:var(--dm-r-field-sq)}}
.dm-otp-d:focus{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-field-line-strong) 50%,transparent)}
/* (not .pop, which is the shared popover class and would hide the box) */
.dm-otp-d.dm-bump{transform:scale(1.06)}
.dm-otp-in.dm-vf .dm-otp-d{color:var(--dm-fg2)}
/* a wrong code keeps its red edge, focused or not, until typing starts it over */
.dm-otp-in.dm-bad .dm-otp-d{background:var(--dm-bad-bg);color:var(--dm-bad);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-bad) 55%,transparent)}
.dm-otp-in.dm-bad{animation:dm-otpsh .42s}
@keyframes dm-otpsh{20%{transform:translateX(-6px)}40%{transform:translateX(5px)}60%{transform:translateX(-3px)}80%{transform:translateX(2px)}}
.dm-otp-in.dm-ok .dm-otp-d{background:var(--dm-ok-bg);color:var(--dm-ok);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-ok) 35%,transparent)}
.dm-otp-m{margin:var(--dm-s-2) 0 0;min-height:20px;display:inline-flex;align-items:center;justify-content:center;gap:6px;font-size:12.5px;color:var(--dm-fg2)}
.dm-otp-m .dm-spin{width:11px;height:11px}
.dm-otp-m.dm-bad{color:var(--dm-bad)}
.dm-otp-m.dm-ok{color:var(--dm-ok)}
.dm-otp-f{margin:2px 0 0;font-size:12.5px}
.dm-otp-f button{color:var(--dm-accent-fg,var(--dm-accent));font-weight:500;font-size:12.5px}
otp-field.htmlHTML · 13 lines
<div class="card" data-c="otp-field">
  <div class="stage-in">
    <div class="otp panel">
      <span class="otp-ic"><svg class="i s"><use href="#i-mail"/></svg></span>
      <b class="otp-t">Check your email</b>
      <p class="muted otp-s">Enter the 6-digit code we sent to [email protected]</p>
      <div class="otp-in" role="group" aria-label="Verification code"></div>
      <p class="otp-m" aria-live="polite"></p>
      <p class="muted otp-f"><span class="otp-rs">Resend code in <span class="num">0:30</span></span></p>
    </div>
  </div>
</div>
otp-field.cssHTML · 27 lines
/* ---------- OTP field ---------- */
/* a one-time code, one box per digit: typing moves along, Backspace moves back, and a paste fills every box. A wrong
   code shakes and turns red; typing again starts it over */
.otp{position:relative;width:100%;max-width:360px;margin:0 auto;padding:22px var(--s-5) 18px;display:flex;flex-direction:column;align-items:center;gap:6px;text-align:center}
.otp-ic{width:40px;height:40px;border-radius:var(--r-in);background:var(--chip);color:var(--fg2);display:grid;place-items:center;margin-bottom:6px}
.otp-t{font-weight:500;font-size:14px}
.otp-s{margin:0 0 10px;font-size:13px;line-height:1.45;max-width:260px}
.otp-in{display:flex;align-items:center;justify-content:center;gap:6px;width:100%;max-width:286px}
.otp-in .sep{flex:none;width:8px;height:1.5px;border-radius:1px;background:var(--rail);margin:0 2px}
.otp-d{flex:1 1 0;min-width:0;max-width:40px;height:var(--field-h);padding:0;border:0;outline:0;border-radius:var(--r-field);background:var(--surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--field-line) 50%,transparent);text-align:center;font:inherit;font-size:14px;font-weight:500;font-variant-numeric:tabular-nums;color:var(--fg);caret-color:var(--accent-mark,var(--accent));transition:box-shadow .25s,background .25s,color .25s,transform .25s var(--ease)}
@supports (corner-shape:squircle){.otp-d{corner-shape:squircle;border-radius:var(--r-field-sq)}}
.otp-d:focus{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--field-line-strong) 50%,transparent)}
/* (not .pop, which is the shared popover class and would hide the box) */
.otp-d.bump{transform:scale(1.06)}
.otp-in.vf .otp-d{color:var(--fg2)}
/* a wrong code keeps its red edge, focused or not, until typing starts it over */
.otp-in.bad .otp-d{background:var(--bad-bg);color:var(--bad);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--bad) 55%,transparent)}
.otp-in.bad{animation:otpsh .42s}
@keyframes otpsh{20%{transform:translateX(-6px)}40%{transform:translateX(5px)}60%{transform:translateX(-3px)}80%{transform:translateX(2px)}}
.otp-in.ok .otp-d{background:var(--ok-bg);color:var(--ok);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--ok) 35%,transparent)}
.otp-m{margin:var(--s-2) 0 0;min-height:20px;display:inline-flex;align-items:center;justify-content:center;gap:6px;font-size:12.5px;color:var(--fg2)}
.otp-m .spin{width:11px;height:11px}
.otp-m.bad{color:var(--bad)}
.otp-m.ok{color:var(--ok)}
.otp-f{margin:2px 0 0;font-size:12.5px}
.otp-f button{color:var(--accent-fg,var(--accent));font-weight:500;font-size:12.5px}
otp-field.jsHTML · 61 lines
DM.def('otp-field', (r, c) => {
  r.innerHTML = r.innerHTML.replace('{mail}', I('mail'));
  const CODE = '482157';
  const box = $('.otp-in', r), msg = $('.otp-m', r), rs = $('.otp-rs', r);
  // one box per digit, with a short dash between the two halves. The first box carries autocomplete="one-time-code",
  // so a phone can offer the code from a message
  box.innerHTML = Array.from({ length:6 }, (_, i) => `${i === 3 ? '<span class="sep" aria-hidden="true"></span>' : ''}<input class="otp-d" inputmode="numeric" pattern="[0-9]*" maxlength="1" autocomplete="${i ? 'off' : 'one-time-code'}" aria-label="Digit ${i + 1} of 6">`).join('');
  const ins = $$('.otp-d', box);
  let state = 'idle', tries = 3;
  const say = (h, cls) => { msg.className = 'otp-m' + (cls ? ' ' + cls : ''); msg.innerHTML = h; if (h) enter(msg); };
  const code = () => ins.map(i => i.value).join('');
  const pop = el => { el.classList.add('bump'); c.later(160, () => el.classList.remove('bump')); };
  // a wrong code stays red until you type again, which starts it over
  function fresh(){ if (state !== 'bad') return; state = 'idle'; box.classList.remove('bad'); ins.forEach(i => { i.value = ''; i.removeAttribute('aria-invalid'); }); say(''); }
  function put(i, d){ fresh(); ins[i].value = d; pop(ins[i]); if (code().length === 6) verify(); }
  // the code is checked as soon as the last box is filled
  function verify(){
    if (state !== 'idle') return; state = 'vf';
    c.run(async () => {
      box.classList.add('vf'); ins.forEach(i => { i.readOnly = true; i.blur(); }); say('<span class="spin"></span>Checking the code');
      await c.wait(900); box.classList.remove('vf'); ins.forEach(i => i.readOnly = false);
      if (code() === CODE){
        state = 'ok'; box.classList.add('ok'); ins.forEach(i => i.readOnly = true);
        say(`${I('ok','xs')}Email verified`, 'ok'); rs.parentElement.hidden = true; return;
      }
      state = 'bad'; tries--; box.classList.remove('bad'); void box.offsetWidth; box.classList.add('bad'); ins.forEach(i => i.setAttribute('aria-invalid', 'true'));
      say(`${I('alert','xs')}That code doesn't match. ${tries ? `${tries} ${tries === 1 ? 'try' : 'tries'} left` : 'Send a new code to try again'}`, 'bad');
    });
  }
  // typing a digit fills the box and moves on; Backspace clears it, or the one before; the arrows move; a paste fills
  // every box from the first
  ins.forEach((inp, i) => {
    inp.oninput = () => { const d = inp.value.replace(/\D/g, ''); inp.value = ''; if (!d) return; if (d.length > 1) return fill(d); put(i, d); if (ins[i + 1] && state === 'idle') ins[i + 1].focus(); };
    inp.onkeydown = e => {
      if (e.key === 'Backspace'){ e.preventDefault(); if (state === 'bad'){ fresh(); ins[0].focus(); } else if (inp.value){ inp.value = ''; } else if (ins[i - 1]){ ins[i - 1].value = ''; ins[i - 1].focus(); } }
      else if (e.key === 'ArrowLeft' && ins[i - 1]){ e.preventDefault(); ins[i - 1].focus(); }
      else if (e.key === 'ArrowRight' && ins[i + 1]){ e.preventDefault(); ins[i + 1].focus(); }
      else if (/^\d$/.test(e.key) && inp.value){ e.preventDefault(); const j = state === 'bad' ? 0 : i; put(j, e.key); if (ins[j + 1] && state === 'idle') ins[j + 1].focus(); }
    };
    inp.onpaste = e => { e.preventDefault(); fill((e.clipboardData || window.clipboardData).getData('text')); };
    inp.onfocus = () => inp.select();
  });
  function fill(text, focus = true){
    const d = text.replace(/\D/g, '').slice(0, 6); if (!d) return;
    fresh(); ins.forEach((inp, i) => { inp.value = d[i] || ''; if (d[i]) c.later(i * 40, () => pop(inp)); });
    if (d.length === 6) verify(); else if (focus) ins[d.length].focus();
  }
  // the resend link waits thirty seconds
  let left = 30; const ns = $('.num', rs);
  const iv = c.every(1000, () => { if (state === 'ok') return clearInterval(iv); left--; ns.textContent = '0:' + String(left).padStart(2, '0'); if (left <= 0){ clearInterval(iv); rs.innerHTML = '<button>Resend code</button>'; $('button', rs).onclick = () => { rs.textContent = 'A new code is on its way'; }; } });
  // the preview's story: paste a code with two digits swapped, see it fail, and type the right one
  c.story(async s => {
    await s.beat(3);
    await s.tap(ins[0]); s.away();
    await s.beat(1); fill('482175', false);
    await s.until(() => state === 'bad'); await s.beat(4);
    for (let i = 0; i < 6; i++){ put(i, CODE[i]); await s.beat(1); }
    await s.until(() => state === 'ok'); await s.beat(2);
  });
});
06

Questions

What people ask about it.

Does it check the code?
No. onComplete gives you the code. Set status to checking, then success or error from what your server says.
Why one box per digit?
People can see how many digits are left and where a typo is. A paste still fills them all at once, so it's no slower.
Can the code have letters?
Not yet. Each box takes a digit, which is what most email and text codes use.
What happens after a wrong code?
The digits stay red so people can see what they entered. Typing again, or Backspace, clears them and starts from the first box.
07

More in Forms and inputs

Buttons, fields, pickers and sign-in flows for any product.

ButtonsEvery button style, with loading, success and hold-to-confirm states.
BadgeSmall labels for plans, environments and status, with a dot, an icon or a remove button.
TooltipQuiet labels on hover, with shortcuts and a richer preview card.
Search inputSearch with scoped filters, recent queries and an agent answer.
Country selectA searchable select with country codes, currency and keyboard support.
Multi-selectA combobox that filters as you type and keeps your picks as chips.
Social sign-inOne-tap providers that remember the last one you used.
Sign inEmail first, then the one-time code from the email, copied or typed in.
All 11 in Forms and inputs →