Skip to content
Star on GitHub
Forms and inputsSign in
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

Sign in

Email first, then the one-time code from the email, copied or typed in. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.

$npx designme add sign-in
Sign inWe will email you a code
01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add sign-in

React

$npx designme add sign-in --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.

Sign in is a passwordless sign-in card in three steps: the person enters an email, types the one-time code they were sent into separate digit boxes, and sees a short confirmation. The code checks itself as soon as the last box is filled. It suits agent products that sign people in by email instead of with a password.

  • When people sign in with an emailed one-time code instead of a password
  • When the code should be checked the moment it is complete, with no extra submit button
  • When a wrong code should clear the boxes and let the person try again in place
  • When you want a resend countdown so people don't request codes over and over
03

Usage

Import it and drive it from your own state.

Import

import { SignIn } from '@/components/designme/sign-in/SignIn';

Example

The demo above as a React component, 38 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 { useRef, useState } from 'react';
import { SignIn, type SignInStep } from './SignIn';
import { useStory, useWait } from '../lib/useScript';

const CODE = '482157';

/* ---------- Sign in: type an email and send it, the email arrives, copy its code and paste it in ---------- */
export default function SignInDemo() {
  const ref = useRef<HTMLDivElement>(null);
  const [email, setEmail] = useState('');
  const [step, setStep] = useState<SignInStep>('email');
  const [code, setCode] = useState('');
  const wait = useWait();

  // the preview's story; anyone can take over and go through it with their own email and any 6 digits
  useStory(ref, async ({ wait, press, taken }) => {
    await wait(1000);
    // taking over while it types takes the letters back
    let typed = '', typing = true;
    taken(() => { if (typing) setEmail(''); });
    for (const ch of '[email protected]') { typed += ch; setEmail(typed); await wait(40); }
    typing = false;
    await wait(500); press('.dm-sgx-go');
    await wait(2800); press('.dm-sgx-cp');
    await wait(1000); setCode(CODE);
  });

  return (
    <div ref={ref} style={{ width: '100%' }}>
      <SignIn email={email} onEmailChange={setEmail} step={step} onStepChange={setStep} code={code} onCodeChange={setCode}
        onSendCode={() => wait(800)} onVerify={() => wait(1100).then(() => true)}
        inbox={{ code: CODE, text: <>Your sign-in code is <span className="dm-num">482 157</span></> }} hint="Copy it from the email above" />
    </div>
  );
}
04

Props

Everything SignIn takes.

PropTypeWhat it does
titleReactNodeDefaults to "Sign in"
subtitleReactNodeDefaults to "We will email you a code"
labelstringThe email field's label. Defaults to "Email"
placeholderstring
sendLabelstringDefaults to "Send code"
emailstringThe email address. Leave it out to let the field hold its own
defaultEmailstring
onEmailChange(email: string) => void
stepSignInStepWhich screen shows: the email, the code or the confirmation. Leave it out to let the form move itself along
defaultStepSignInStep
onStepChange(step: SignInStep) => void
onSendCode(email: string) => void | Promise<void>Called with a valid email. Return a promise and the button spins until it settles; if it rejects, its message shows under the field
sendingbooleanShows the spinner on the button, for when you send the code yourself
codestringThe digits typed so far. A space stands for an empty box, so "48 1" has the third box empty
defaultCodestring
onCodeChange(code: string) => void
codeLengthnumberHow many boxes. Defaults to 6
onVerify(code: string, email: string) => boolean | void | Promise<boolean | void>Called once every box is filled. Return false, or a promise of false, for a wrong code; the boxes clear and say so
onFillRequest() => voidCalled when the person clicks the code boxes while some are empty, for filling the code in for them
inbox{ from?: ReactNode; text?: ReactNode; code: string; copyLabel?: string; copiedLabel?: string }The email with the code, shown arriving at the top of the code screen the way a phone shows it, with Copy code. Copy puts `code` on the clipboard; pasting it into the boxes fills them all
onCopyCode(code: string) => voidCalled by Copy code
hintReactNodeA quiet note after the resend countdown, like "Click the code to fill it"
resendInnumberSeconds before the code can be sent again. Defaults to 30
onResend(email: string) => voidCalled by Resend code, once the countdown ends; the countdown starts again
onSignedIn(email: string) => voidCalled when the confirmation shows
codeTitleReactNode
codeSubtitleReactNode
doneTitleReactNode
doneDetailReactNode
emptyEmailLabelstring
badEmailLabelstring
wrongCodeLabelstring
classNamestring
05

Code

The full source, as the CLI installs it.

SignIn.tsxReact · 270 lines
'use client';
import { useEffect, useRef, useState, type ClipboardEvent, type CSSProperties, type FormEvent, type KeyboardEvent, type PointerEvent, type ReactNode } from 'react';
import { cx } from '../lib/cx';
import { useControllable } from '../lib/hooks';
import { Icon } from '../lib/icons';
import { enter, Fold, Spin, Swap } from '../lib/motion';
import '../designme.css';
import './sign-in.css';

export type SignInStep = 'email' | 'code' | 'done';

export interface SignInProps {
  /** Defaults to "Sign in" */
  title?: ReactNode;
  /** Defaults to "We will email you a code" */
  subtitle?: ReactNode;
  /** The email field's label. Defaults to "Email" */
  label?: string;
  placeholder?: string;
  /** Defaults to "Send code" */
  sendLabel?: string;
  /** The email address. Leave it out to let the field hold its own */
  email?: string;
  defaultEmail?: string;
  onEmailChange?: (email: string) => void;
  /** Which screen shows: the email, the code or the confirmation. Leave it out to let the form move itself along */
  step?: SignInStep;
  defaultStep?: SignInStep;
  onStepChange?: (step: SignInStep) => void;
  /** Called with a valid email. Return a promise and the button spins until it settles; if it rejects, its message shows under the field */
  onSendCode?: (email: string) => void | Promise<void>;
  /** Shows the spinner on the button, for when you send the code yourself */
  sending?: boolean;
  /** The digits typed so far. A space stands for an empty box, so "48 1" has the third box empty */
  code?: string;
  defaultCode?: string;
  onCodeChange?: (code: string) => void;
  /** How many boxes. Defaults to 6 */
  codeLength?: number;
  /** Called once every box is filled. Return false, or a promise of false, for a wrong code; the boxes clear and say so */
  onVerify?: (code: string, email: string) => boolean | void | Promise<boolean | void>;
  /** Called when the person clicks the code boxes while some are empty, for filling the code in for them */
  onFillRequest?: () => void;
  /** The email with the code, shown arriving at the top of the code screen the way a phone shows it, with Copy code.
      Copy puts `code` on the clipboard; pasting it into the boxes fills them all */
  inbox?: { from?: ReactNode; text?: ReactNode; code: string; copyLabel?: string; copiedLabel?: string };
  /** Called by Copy code */
  onCopyCode?: (code: string) => void;
  /** A quiet note after the resend countdown, like "Click the code to fill it" */
  hint?: ReactNode;
  /** Seconds before the code can be sent again. Defaults to 30 */
  resendIn?: number;
  /** Called by Resend code, once the countdown ends; the countdown starts again */
  onResend?: (email: string) => void;
  /** Called when the confirmation shows */
  onSignedIn?: (email: string) => void;
  codeTitle?: ReactNode;
  codeSubtitle?: ReactNode;
  doneTitle?: ReactNode;
  doneDetail?: ReactNode;
  emptyEmailLabel?: string;
  badEmailLabel?: string;
  wrongCodeLabel?: string;
  className?: string;
}

const EMAIL = /^\S+@\S+\.\S+$/;
const digit = (c: string | undefined) => (c ?? '').replace(/\D/g, '');

/** Sign in with an emailed code: the email, then the code boxes, then a short confirmation. */
export function SignIn({
  title = 'Sign in', subtitle = 'We will email you a code', label = 'Email', placeholder = '[email protected]', sendLabel = 'Send code',
  email, defaultEmail = '', onEmailChange, step, defaultStep = 'email', onStepChange, onSendCode, sending = false,
  code, defaultCode = '', onCodeChange, codeLength = 6, onVerify, onFillRequest, inbox, onCopyCode, hint, resendIn = 30, onResend, onSignedIn,
  codeTitle = 'Check your inbox', codeSubtitle = `Enter the ${codeLength} digit code`, doneTitle = "You're in", doneDetail = 'Opening your workspace',
  emptyEmailLabel = 'Enter your email', badEmailLabel = 'That email looks incomplete', wrongCodeLabel = "That code didn't work, try again", className
}: SignInProps) {
  const [mail, setMail] = useControllable(email, defaultEmail, onEmailChange);
  const [now, setStep] = useControllable(step, defaultStep, onStepChange);
  const [digits, setCode] = useControllable(code, defaultCode, onCodeChange);
  const [error, setError] = useState('');
  const [bad, setBad] = useState(false);
  const [busy, setBusy] = useState(false);
  const [check, setCheck] = useState<'idle' | 'verifying' | 'confirmed'>('idle');
  const [wrong, setWrong] = useState(false);
  const [left, setLeft] = useState(resendIn);
  const [pop, setPop] = useState<number[]>([]);
  const field = useRef<HTMLLabelElement>(null);
  const boxes = useRef<(HTMLInputElement | null)[]>([]);
  const focusCode = useRef(false);
  const root = useRef<HTMLDivElement>(null);
  // the email arrives a moment after the code screen shows, and is copied once
  const [arrived, setArrived] = useState(false);
  const [copied, setCopied] = useState(false);
  useEffect(() => {
    if (now !== 'code' || !inbox) { setArrived(false); setCopied(false); return; }
    const t = setTimeout(() => setArrived(true), 900);
    return () => clearTimeout(t);
  }, [now, !!inbox]); // eslint-disable-line react-hooks/exhaustive-deps
  const copy = () => {
    if (!inbox) return;
    try { navigator.clipboard?.writeText(inbox.code).catch(() => {}); } catch { /* the clipboard can be off limits; the code is still on screen */ }
    setCopied(true); onCopyCode?.(inbox.code);
    boxes.current.find(b => b && !b.value)?.focus({ preventScroll: true });
  };
  // the confirmation keeps the height the code screen had, so it stays centred where it sits
  const [held, setHeld] = useState<number>();

  // a new screen slides in, except the one the form starts on; the countdown restarts with every code
  const [seen, setSeen] = useState(now);
  const [moved, setMoved] = useState(false);
  if (seen !== now) {
    setSeen(now); setMoved(true);
    if (now === 'code') { setLeft(resendIn); setWrong(false); }
    if (now !== 'done') setCheck('idle');
  }

  const chars = Array.from({ length: codeLength }, (_, i) => digit(digits[i]));
  const write = (next: string[]) => setCode(next.map(c => c || ' ').join('').trimEnd());

  // each digit pops a little as it lands, typed or filled in
  const before = useRef(chars);
  useEffect(() => {
    const was = before.current; before.current = chars;
    const fresh = chars.flatMap((c, i) => (c && !was[i] ? [i] : []));
    if (!fresh.length) return;
    setPop(p => [...p, ...fresh]);
    const t = setTimeout(() => setPop(p => p.filter(i => !fresh.includes(i))), 150);
    return () => clearTimeout(t);
  }, [digits]); // eslint-disable-line react-hooks/exhaustive-deps

  useEffect(() => {
    if (now !== 'code' || !focusCode.current) return;
    focusCode.current = false; boxes.current[0]?.focus({ preventScroll: true });
  }, [now]);

  // the countdown pauses while the code is checked
  useEffect(() => {
    if (now !== 'code' || check !== 'idle' || left <= 0) return;
    const t = setTimeout(() => setLeft(l => l - 1), 1000);
    return () => clearTimeout(t);
  }, [now, check, left]);

  // every box filled: check the code
  const full = chars.every(Boolean);
  useEffect(() => {
    if (now !== 'code' || check !== 'idle' || !full) return;
    let alive = true;
    setCheck('verifying'); setWrong(false);
    boxes.current.forEach(b => b?.blur());
    (async () => {
      let ok: boolean | void = true;
      try { ok = await onVerify?.(chars.join(''), mail.trim()); } catch { ok = false; }
      if (!alive) return;
      if (ok === false) { setCheck('idle'); setWrong(true); write([]); boxes.current[0]?.focus({ preventScroll: true }); return; }
      setCheck('confirmed');
    })();
    return () => { alive = false; };
  }, [full, now]); // eslint-disable-line react-hooks/exhaustive-deps

  useEffect(() => {
    if (check !== 'confirmed') return;
    const t = setTimeout(() => { setHeld(root.current?.offsetHeight); setStep('done'); onSignedIn?.(mail.trim()); }, 1100);
    return () => clearTimeout(t);
  }, [check]); // eslint-disable-line react-hooks/exhaustive-deps

  const send = async (e: FormEvent) => {
    e.preventDefault();
    if (busy || sending) return;
    const v = mail.trim();
    if (!EMAIL.test(v)) {
      // the field shakes again on every try
      const f = field.current;
      if (bad && f) { f.classList.remove('dm-bad'); void f.offsetWidth; f.classList.add('dm-bad'); } else setBad(true);
      setError(v ? badEmailLabel : emptyEmailLabel);
      return;
    }
    setBad(false); setError(''); setBusy(true);
    try { await onSendCode?.(v); } catch (err) { setBusy(false); setError(err instanceof Error && err.message ? err.message : 'The code could not be sent'); return; }
    setBusy(false); write([]); focusCode.current = true; setStep('code');
  };

  const back = () => { setBusy(false); write([]); setStep('email'); };
  const resend = () => { onResend?.(mail.trim()); setLeft(resendIn); };

  const type = (k: number, value: string) => {
    if (check !== 'idle') return;
    const typed = digit(value), next = chars.slice();
    setWrong(false);
    // a whole code at once (an autofilled one-time code) spreads over the boxes; otherwise the last digit typed wins
    if (typed.length > 1 && !chars[k]) {
      typed.slice(0, codeLength - k).split('').forEach((d, j) => { next[k + j] = d; });
      write(next); boxes.current[Math.min(codeLength - 1, k + typed.length)]?.focus();
      return;
    }
    next[k] = typed.slice(-1); write(next);
    if (next[k]) boxes.current[k + 1]?.focus();
  };
  const key = (k: number, e: KeyboardEvent<HTMLInputElement>) => {
    if (e.key !== 'Backspace' || chars[k] || k === 0) return;
    const next = chars.slice(); next[k - 1] = ''; write(next); boxes.current[k - 1]?.focus();
  };
  const paste = (e: ClipboardEvent<HTMLInputElement>) => {
    const t = digit(e.clipboardData.getData('text')).slice(0, codeLength);
    if (!t || check !== 'idle') return;
    e.preventDefault();
    const next = chars.slice(); t.split('').forEach((d, j) => { next[j] = d; });
    write(next); setWrong(false); boxes.current[Math.min(codeLength - 1, t.length)]?.focus();
  };
  const fill = (e: PointerEvent<HTMLDivElement>) => {
    if (!onFillRequest || full || check !== 'idle') return;
    e.preventDefault(); onFillRequest();
  };

  const slide = moved ? 'dm-in' : undefined;
  return (
    <div data-dm="" ref={root} className={cx('dm-sgx dm-panel', now === 'done' && held && 'dm-held', className)} style={now === 'done' && held ? { minHeight: held } : undefined}>
      {now === 'email' && (
        <form key="email" className={cx('dm-sgx-st dm-sgx-1', slide)} noValidate onSubmit={send}>
          <b>{title}</b><span className="dm-muted">{subtitle}</span>
          <label ref={field} className={cx('dm-sgx-f', bad && 'dm-bad')}>
            <span>{label}</span>
            <input type="email" name="email" autoComplete="email" className="dm-sgx-em" placeholder={placeholder} value={mail}
              aria-invalid={bad || undefined} onChange={e => setMail(e.target.value)} />
          </label>
          <Swap className="dm-sgx-err">{error}</Swap>
          <button type="submit" className="dm-pill dm-primary dm-sgx-go">{busy || sending ? <Spin /> : sendLabel}</button>
        </form>
      )}
      {now === 'code' && (
        <div key="code" className={cx('dm-sgx-st dm-sgx-2', slide)}>
          {inbox && (
            <Fold open={arrived} className="dm-sgx-ntf">
              <div className="dm-sgx-nt" role="status">
                <span className="dm-sgx-ni"><Icon name="mail" size="xs" /></span>
                <span className="dm-t"><b>{inbox.from ?? 'Designme'} <span className="dm-muted">now</span></b><span>{inbox.text ?? <>Your sign-in code is <span className="dm-num">{inbox.code}</span></>}</span></span>
                <button type="button" className="dm-pill dm-sm dm-sgx-cp" disabled={copied} onClick={copy}><Swap>{copied ? inbox.copiedLabel ?? 'Copied' : inbox.copyLabel ?? 'Copy code'}</Swap></button>
              </div>
            </Fold>
          )}
          <button type="button" className="dm-sgx-bk" onClick={back}><Icon name="chev-l" size="xs" /><span className="dm-sgx-to">{mail.trim()}</span></button>
          <b>{codeTitle}</b><span className="dm-muted">{codeSubtitle}</span>
          <div className={cx('dm-sgx-code', check === 'verifying' && 'dm-vf', check === 'confirmed' && 'dm-ok')} role="group" aria-label="Code" onPointerDown={fill}>
            {chars.map((c, k) => (
              <input key={k} ref={el => { boxes.current[k] = el; }} inputMode="numeric" autoComplete={k === 0 ? 'one-time-code' : 'off'}
                aria-label={`Digit ${k + 1}`} className={pop.includes(k) ? 'dm-f' : undefined} style={{ '--dm-i': k } as CSSProperties}
                value={c} readOnly={check !== 'idle'} onChange={e => type(k, e.target.value)} onKeyDown={e => key(k, e)} onPaste={paste} />
            ))}
          </div>
          <span className="dm-muted dm-sgx-rs" aria-live="polite">
            {check === 'verifying' ? <span className="dm-sgx-v"><Spin />Verifying code</span>
              : check === 'confirmed' ? <span className="dm-sgx-v dm-ok dm-enter"><Icon name="check" size="xs" />Code confirmed</span>
              : wrong ? <span style={{ color: 'var(--dm-bad)' }}>{wrongCodeLabel}</span>
              : left > 0 ? <>Resend in <span className="dm-num">{left}</span>s{hint && <> · <span className="dm-sgx-hint">{hint}</span></>}</>
              : <button type="button" style={{ color: 'var(--dm-accent-fg,var(--dm-accent))', fontWeight: 500 }} onClick={resend}>Resend code</button>}
          </span>
        </div>
      )}
      {now === 'done' && (
        <div className="dm-sgx-done" role="status">
          <svg className="dm-sgx-ck" viewBox="0 0 52 52" aria-hidden="true"><circle cx="26" cy="26" r="24" /><path d="m16 27 7 7 13-14" /></svg>
          <b {...enter(250)}>{doneTitle}</b>
          <span className="dm-muted dm-enter" style={enter(340).style}>{doneDetail}</span>
          <span className="dm-sgx-bar dm-enter" style={enter(430).style}><i /></span>
        </div>
      )}
    </div>
  );
}
sign-in.cssReact · 53 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Sign in ---------- */
.dm-sgx{padding:var(--dm-s-5);max-width:320px;margin:0 auto;width:100%;overflow:hidden}
.dm-sgx-st{display:flex;flex-direction:column;gap:6px}
.dm-sgx-st>b{font-weight:500;font-size:14px}
.dm-sgx-st>.dm-muted{font-size:12.5px;margin-bottom:var(--dm-s-2)}
.dm-sgx-st.dm-in{animation:dm-sgin .5s var(--dm-ease) both}
@keyframes dm-sgin{from{opacity:0;transform:translateX(20px);filter:blur(4px)}}
.dm-sgx-f{display:flex;flex-direction:column;gap:5px;font-size:12.5px;font-weight:500}
.dm-sgx-f input{height:var(--dm-field-h);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);padding:0 var(--dm-s-3);font:inherit;font-size:13.5px;font-weight:400;color:var(--dm-fg);transition:box-shadow var(--dm-t-fast)}
@supports (corner-shape:squircle){.dm-sgx-f input{corner-shape:squircle;border-radius:var(--dm-r-field-sq)}}
.dm-sgx-f input::placeholder{color:var(--dm-muted)}
.dm-sgx-f input:focus{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-field-line-strong) 50%,transparent)}
/* a field marked wrong keeps its red edge, focused or not, until it is fixed; the message says what is wrong */
.dm-sgx-f.dm-bad input{box-shadow:inset 0 0 0 1px var(--dm-bad);animation:dm-sgsh .4s}
@keyframes dm-sgsh{20%,60%{transform:translateX(-4px)}40%,80%{transform:translateX(4px)}}
.dm-sgx-err{font-size:12px;color:var(--dm-bad);min-height:16px}
.dm-sgx-go{height:40px;width:100%}
.dm-sgx-bk{display:flex;align-items:center;gap:6px;font-size:12.5px;color:var(--dm-muted);align-self:flex-start;margin-bottom:6px}
@media (hover:hover) and (pointer:fine){.dm-sgx-bk:hover{color:var(--dm-fg)}}
.dm-sgx-code{display:grid;grid-template-columns:repeat(6,1fr);gap:6px}
.dm-sgx-code input{width:100%;height:var(--dm-field-h);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-variant-numeric:tabular-nums;color:var(--dm-fg);transition:box-shadow var(--dm-t-fast),background .25s,transform .3s var(--dm-ease)}
@supports (corner-shape:squircle){.dm-sgx-code input{corner-shape:squircle;border-radius:var(--dm-r-field-sq)}}
.dm-sgx-code input:focus{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-field-line-strong) 50%,transparent)}
.dm-sgx-code input.dm-f{transform:scale(1.04)}
/* the email with the code, the way a phone shows it as it arrives */
.dm-sgx-nt{display:flex;align-items:center;gap:10px;padding:10px 10px 10px var(--dm-s-3);margin-bottom:10px;border-radius:14px;background:var(--dm-raised);box-shadow:var(--dm-shadow-pop);font-size:12.5px;line-height:1.4}
.dm-sgx-ni{width:30px;height:30px;border-radius:9px;background:var(--dm-accent);color:var(--dm-on-accent);display:grid;place-items:center;flex:none}
.dm-sgx-nt .dm-t{flex:1;min-width:0;display:flex;flex-direction:column}
.dm-sgx-nt .dm-t b{font-weight:500;font-size:12.5px}
.dm-sgx-nt .dm-t b .dm-muted{font-weight:400;font-size:12px;margin-left:var(--dm-s-1)}
.dm-sgx-nt .dm-t .dm-num{color:var(--dm-fg);font-weight:500}
.dm-sgx-cp{flex:none}
.dm-sgx-code input{caret-color:var(--dm-accent-fg,var(--dm-accent))}
.dm-sgx-code.dm-vf input{animation:dm-sgvf 1s var(--dm-ease-io) infinite;animation-delay:calc(var(--dm-i) * 90ms)}
@keyframes dm-sgvf{50%{background:var(--dm-hover);transform:translateY(-2px)}}
.dm-sgx-code.dm-ok input{background:var(--dm-ok-bg);color:var(--dm-ok);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-ok) 30%,transparent);animation:dm-sgok .5s var(--dm-ease) both;animation-delay:calc(var(--dm-i) * 70ms)}
@keyframes dm-sgok{0%{background:var(--dm-surface);color:var(--dm-fg)}45%{transform:scale(1.1)}100%{transform:none}}
.dm-sgx-v{display:inline-flex;align-items:center;gap:7px;color:var(--dm-fg2)}
.dm-sgx-v .dm-spin{width:11px;height:11px}
.dm-sgx-v.dm-ok{color:var(--dm-ok)}
.dm-sgx-hint{color:var(--dm-fg2)}
.dm-sgx-ck{width:52px;height:52px;fill:none;stroke-width:2.5;stroke-linecap:round;stroke-linejoin:round}
.dm-sgx-ck circle{stroke:var(--dm-accent-mark,var(--dm-accent));stroke-dasharray:151;stroke-dashoffset:151;animation:dm-sgdraw .7s var(--dm-ease) forwards}
.dm-sgx-ck path{stroke:var(--dm-accent-mark,var(--dm-accent));stroke-dasharray:30;stroke-dashoffset:30;animation:dm-sgdraw .4s var(--dm-ease) .55s forwards}
@keyframes dm-sgdraw{to{stroke-dashoffset:0}}
.dm-sgx-bar{width:120px;height:3px;border-radius:2px;background:var(--dm-line);overflow:hidden;margin-top:var(--dm-s-1)}
.dm-sgx-bar i{display:block;height:100%;width:0;background:var(--dm-accent-mark,var(--dm-accent));animation:dm-sgbar 1.8s var(--dm-ease) .6s forwards}
@keyframes dm-sgbar{to{width:100%}}
.dm-sgx-rs{font-size:12px;margin-top:6px}
.dm-sgx-done{display:flex;flex-direction:column;align-items:center;gap:var(--dm-s-2);padding:var(--dm-s-5) 0;font-size:13px;text-align:center}
.dm-sgx-done>b{font-weight:500;font-size:14px}
sign-in.htmlHTML · 20 lines
<div class="card" data-c="sign-in">
  <div class="stage-in">
    <div class="sgx panel">
      <div class="sgx-st sgx-1">
        <b>Sign in</b><span class="muted">We will email you a code</span>
        <label class="sgx-f"><span>Email</span><input type="email" class="sgx-em" placeholder="[email protected]" autocomplete="off"></label>
        <span class="sgx-err"></span>
        <button class="pill primary sgx-go">Send code</button>
      </div>
      <div class="sgx-st sgx-2" hidden>
        <div class="fold shut sgx-ntf"><div><div class="sgx-nt" role="status"><span class="sgx-ni"><svg class="i xs"><use href="#i-mail"/></svg></span><span class="t"><b>Designme <span class="muted">now</span></b><span>Your sign-in code is <span class="num">482 157</span></span></span><button class="pill sm sgx-cp">Copy code</button></div></div></div>
        <button class="sgx-bk"><svg class="i xs"><use href="#i-chev-l"/></svg><span class="sgx-to"></span></button>
        <b>Check your inbox</b><span class="muted">Enter the 6 digit code</span>
        <div class="sgx-code"></div>
        <span class="muted sgx-rs">Resend in <span class="num">30</span>s · <span class="sgx-hint">Copy it from the email above</span></span>
      </div>
    </div>
  </div>
</div>
sign-in.cssHTML · 52 lines
/* ---------- Sign in ---------- */
.sgx{padding:var(--s-5);max-width:320px;margin:0 auto;width:100%;overflow:hidden}
.sgx-st{display:flex;flex-direction:column;gap:6px}
.sgx-st>b{font-weight:500;font-size:14px}
.sgx-st>.muted{font-size:12.5px;margin-bottom:var(--s-2)}
.sgx-st.in{animation:sgin .5s var(--ease) both}
@keyframes sgin{from{opacity:0;transform:translateX(20px);filter:blur(4px)}}
.sgx-f{display:flex;flex-direction:column;gap:5px;font-size:12.5px;font-weight:500}
.sgx-f input{height:var(--field-h);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);padding:0 var(--s-3);font:inherit;font-size:13.5px;font-weight:400;color:var(--fg);transition:box-shadow var(--t-fast)}
@supports (corner-shape:squircle){.sgx-f input{corner-shape:squircle;border-radius:var(--r-field-sq)}}
.sgx-f input::placeholder{color:var(--muted)}
.sgx-f input:focus{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--field-line-strong) 50%,transparent)}
/* a field marked wrong keeps its red edge, focused or not, until it is fixed; the message says what is wrong */
.sgx-f.bad input{box-shadow:inset 0 0 0 1px var(--bad);animation:sgsh .4s}
@keyframes sgsh{20%,60%{transform:translateX(-4px)}40%,80%{transform:translateX(4px)}}
.sgx-err{font-size:12px;color:var(--bad);min-height:16px}
.sgx-go{height:40px;width:100%}
.sgx-bk{display:flex;align-items:center;gap:6px;font-size:12.5px;color:var(--muted);align-self:flex-start;margin-bottom:6px}
@media (hover:hover) and (pointer:fine){.sgx-bk:hover{color:var(--fg)}}
.sgx-code{display:grid;grid-template-columns:repeat(6,1fr);gap:6px}
.sgx-code input{width:100%;height:var(--field-h);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-variant-numeric:tabular-nums;color:var(--fg);transition:box-shadow var(--t-fast),background .25s,transform .3s var(--ease)}
@supports (corner-shape:squircle){.sgx-code input{corner-shape:squircle;border-radius:var(--r-field-sq)}}
.sgx-code input:focus{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--field-line-strong) 50%,transparent)}
.sgx-code input.f{transform:scale(1.04)}
/* the email with the code, the way a phone shows it as it arrives */
.sgx-nt{display:flex;align-items:center;gap:10px;padding:10px 10px 10px var(--s-3);margin-bottom:10px;border-radius:14px;background:var(--raised);box-shadow:var(--shadow-pop);font-size:12.5px;line-height:1.4}
.sgx-ni{width:30px;height:30px;border-radius:9px;background:var(--accent);color:var(--on-accent);display:grid;place-items:center;flex:none}
.sgx-nt .t{flex:1;min-width:0;display:flex;flex-direction:column}
.sgx-nt .t b{font-weight:500;font-size:12.5px}
.sgx-nt .t b .muted{font-weight:400;font-size:12px;margin-left:var(--s-1)}
.sgx-nt .t .num{color:var(--fg);font-weight:500}
.sgx-cp{flex:none}
.sgx-code input{caret-color:var(--accent-fg,var(--accent))}
.sgx-code.vf input{animation:sgvf 1s var(--ease-io) infinite;animation-delay:calc(var(--i) * 90ms)}
@keyframes sgvf{50%{background:var(--hover);transform:translateY(-2px)}}
.sgx-code.ok input{background:var(--ok-bg);color:var(--ok);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--ok) 30%,transparent);animation:sgok .5s var(--ease) both;animation-delay:calc(var(--i) * 70ms)}
@keyframes sgok{0%{background:var(--surface);color:var(--fg)}45%{transform:scale(1.1)}100%{transform:none}}
.sgx-v{display:inline-flex;align-items:center;gap:7px;color:var(--fg2)}
.sgx-v .spin{width:11px;height:11px}
.sgx-v.ok{color:var(--ok)}
.sgx-hint{color:var(--fg2)}
.sgx-ck{width:52px;height:52px;fill:none;stroke-width:2.5;stroke-linecap:round;stroke-linejoin:round}
.sgx-ck circle{stroke:var(--accent-mark,var(--accent));stroke-dasharray:151;stroke-dashoffset:151;animation:sgdraw .7s var(--ease) forwards}
.sgx-ck path{stroke:var(--accent-mark,var(--accent));stroke-dasharray:30;stroke-dashoffset:30;animation:sgdraw .4s var(--ease) .55s forwards}
@keyframes sgdraw{to{stroke-dashoffset:0}}
.sgx-bar{width:120px;height:3px;border-radius:2px;background:var(--line);overflow:hidden;margin-top:var(--s-1)}
.sgx-bar i{display:block;height:100%;width:0;background:var(--accent-mark,var(--accent));animation:sgbar 1.8s var(--ease) .6s forwards}
@keyframes sgbar{to{width:100%}}
.sgx-rs{font-size:12px;margin-top:6px}
.sgx-done{display:flex;flex-direction:column;align-items:center;gap:var(--s-2);padding:var(--s-5) 0;font-size:13px;text-align:center}
.sgx-done>b{font-weight:500;font-size:14px}
sign-in.jsHTML · 44 lines
DM.def('sign-in', (r, c) => {
  r.innerHTML = r.innerHTML.replace('{l}', I('chev-l','xs')).replace('{mail}', I('mail','xs'));
  const card = $('.sgx', r), ntf = $('.sgx-ntf', r), cp = $('.sgx-cp', r), s1 = $('.sgx-1', r), s2 = $('.sgx-2', r), em = $('.sgx-em', r), err = $('.sgx-err', r), go = $('.sgx-go', r), code = $('.sgx-code', r);
  code.innerHTML = Array.from({ length:6 }, (_, i) => `<input inputmode="numeric" maxlength="1" autocomplete="${i ? 'off' : 'one-time-code'}" aria-label="Digit ${i + 1}" style="--i:${i}">`).join('');
  const CODE = '482157';
  const ins = $$('input', code);
  const send = demo => c.run(async () => {
    const v = em.value.trim();
    if (!/^\S+@\S+\.\S+$/.test(v)){ const f = em.parentElement; f.classList.remove('bad'); void f.offsetWidth; f.classList.add('bad'); swap(err, v ? 'That email looks incomplete' : 'Enter your email'); return; }
    em.parentElement.classList.remove('bad'); swap(err, ''); go.innerHTML = '<span class="spin"></span>'; await c.wait(800);
    s1.hidden = true; s2.hidden = false; s2.classList.remove('in'); void s2.offsetWidth; s2.classList.add('in'); $('.sgx-to', r).textContent = v; if (demo !== true && c.mine) ins[0].focus({ preventScroll:true }); c.later(900, () => ntf.classList.remove('shut'));
    let n = 30; const ns = $('.sgx-rs .num', r); const iv = c.every(1000, () => { if (checking || !ns.isConnected) return clearInterval(iv); n--; ns.textContent = n; if (n <= 0){ clearInterval(iv); $('.sgx-rs', r).innerHTML = '<button style="color:var(--accent-fg,var(--accent));font-weight:500">Resend code</button>'; } });
  });
  go.onclick = () => send(); em.onkeydown = e => { if (e.key === 'Enter') send(); };
  $('.sgx-bk', r).onclick = () => { s2.hidden = true; s1.hidden = false; s1.classList.remove('in'); void s1.offsetWidth; s1.classList.add('in'); go.textContent = 'Send code'; ins.forEach(i => i.value = ''); };
  let checking = false;
  /* verifying, then each digit turns green in turn, then the confirmed screen */
  function check(){ if (checking || !ins.every(i => i.value)) return; checking = true; c.run(async () => {
    const rs = $('.sgx-rs', r); ins.forEach(i => { i.blur(); i.readOnly = true; });
    code.classList.add('vf'); rs.innerHTML = '<span class="sgx-v"><span class="spin"></span>Verifying code</span>'; await c.wait(1100);
    code.classList.remove('vf'); code.classList.add('ok'); rs.innerHTML = `<span class="sgx-v ok">${I('check','xs')}Code confirmed</span>`; enter(rs.firstChild); await c.wait(1100);
    hold(card); [...card.children].forEach(k => k.hidden = true);
    const d = html(`<div class="sgx-done"><svg class="sgx-ck" viewBox="0 0 52 52" aria-hidden="true"><circle cx="26" cy="26" r="24"/><path d="m16 27 7 7 13-14"/></svg><b>You're in</b><span class="muted">Opening your workspace</span><span class="sgx-bar"><i></i></span></div>`);
    card.appendChild(d); stagger([...d.children].slice(1), 90, 250); }); }
  // a pasted code fills every box from the first; any 6 digits go through in the demo
  const paste = t => { t = t.replace(/\D/g, '').slice(0, 6); if (!t || checking) return; t.split('').forEach((d, j) => { if (ins[j]) ins[j].value = d; }); if (c.mine) ins[Math.min(5, t.length)].focus({ preventScroll:true }); check(); };
  // Copy puts the code on the clipboard, the way a phone offers it from the email; then it is pasted into the boxes
  cp.onclick = () => { try { navigator.clipboard.writeText(CODE).catch(() => {}); } catch(e){} swap(cp, 'Copied'); cp.disabled = true; swap($('.sgx-hint', r), 'Paste it into the first box'); const at = ins.find(i => !i.value); if (at && c.mine) at.focus({ preventScroll:true }); };
  ins.forEach((i, k) => {
    i.oninput = () => { i.value = i.value.replace(/\D/g, '').slice(-1); if (i.value && ins[k + 1]) ins[k + 1].focus({ preventScroll:true }); check(); };
    i.onkeydown = e => { if (e.key === 'Backspace' && !i.value && ins[k - 1]){ ins[k - 1].focus({ preventScroll:true }); ins[k - 1].value = ''; } };
    i.onpaste = e => { const t = (e.clipboardData.getData('text') || '').replace(/\D/g, ''); if (!t) return; e.preventDefault(); paste(t); };
  });
  // the preview's story: type an email and send it, the email arrives, copy its code and paste it in. The visitor can take
  // over at any point and go through it with their own email and any 6 digits
  c.story(async s => {
    await s.beat(2); await s.tap(em); await s.type(em, '[email protected]', { n:.08 });
    await s.beat(1); await s.tap(go); s.away();
    await s.until(() => !ntf.classList.contains('shut')); await s.beat(3);
    await s.tap(cp); s.away(); await s.beat(2);
    paste(CODE); await s.until(() => !!$('.sgx-done', r)); await s.beat(4);
  });
});
06

Questions

What people ask about it.

How do I check the code against my backend?
Pass onVerify(code, email). Return true, nothing, or a promise of either to accept it. Return false, or throw, and the boxes clear with the wrong code message.
Will a one-time code from the phone's keyboard fill all six boxes?
The first box has autocomplete="one-time-code", and several digits entered at once spread across the boxes from there. Pasting a code into any box fills from the first box.
Can I use a four-digit code instead of six?
Yes. Set codeLength to 4. The default subtitle updates to say how many digits to enter.
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.
OTP fieldA one-time code, one box per digit, that fills from a paste.
All 11 in Forms and inputs →