Forms and inputsSign in
Built by DesignMe, free to use under the MIT licence.
Expert design and dev support for startups and enterprises.
Book a callSign 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.
Install
Add it to your project with the CLI.
HTML, CSS and JavaScript
npx designme add sign-inReact
npx designme add sign-in --reactThe 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.
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
- The email step checks the address shape and shows
emptyEmailLabelorbadEmailLabel, with the field shaking on each failed try. - If
onSendCodereturns a promise, the button spins until it settles, and a rejection's message shows under the field. - The code boxes accept typing, Backspace back to the previous box, and a pasted or autofilled code that spreads across the boxes.
- Pass
inboxand the email with the code arrives at the top of the code screen, the way a phone shows it, with Copy code. Copy puts the code on the clipboard and callsonCopyCode; pasting it into the boxes fills them all. - Once every box is filled,
onVerifyruns; returning false clears the boxes and showswrongCodeLabel. - A resend countdown runs from
resendInseconds, 30 by default, then turns into a Resend code button that callsonResend. - A confirmed code shows a check, then the done screen with
doneTitle, kept at the height the code screen had, and callsonSignedIn.
- Each code box has an
aria-labellike "Digit 1" inside agrouplabelled "Code", and the first box usesautocomplete="one-time-code". - The email input sets
aria-invalidwhen the address is missing or malformed. - The resend line is an
aria-live="polite"region, so verifying, confirmed and wrong code messages are announced. - The done screen has
role="status".
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>
);
}
Props
Everything SignIn takes.
| Prop | Type | What it does |
|---|---|---|
title | ReactNode | Defaults to "Sign in" |
subtitle | ReactNode | Defaults to "We will email you a code" |
label | string | The email field's label. Defaults to "Email" |
placeholder | string | |
sendLabel | string | Defaults to "Send code" |
email | string | The email address. Leave it out to let the field hold its own |
defaultEmail | string | |
onEmailChange | (email: string) => void | |
step | SignInStep | Which screen shows: the email, the code or the confirmation. Leave it out to let the form move itself along |
defaultStep | SignInStep | |
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 |
sending | boolean | Shows the spinner on the button, for when you send the code yourself |
code | string | The digits typed so far. A space stands for an empty box, so "48 1" has the third box empty |
defaultCode | string | |
onCodeChange | (code: string) => void | |
codeLength | number | How 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 | () => void | Called 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) => void | Called by Copy code |
hint | ReactNode | A quiet note after the resend countdown, like "Click the code to fill it" |
resendIn | number | Seconds before the code can be sent again. Defaults to 30 |
onResend | (email: string) => void | Called by Resend code, once the countdown ends; the countdown starts again |
onSignedIn | (email: string) => void | Called when the confirmation shows |
codeTitle | ReactNode | |
codeSubtitle | ReactNode | |
doneTitle | ReactNode | |
doneDetail | ReactNode | |
emptyEmailLabel | string | |
badEmailLabel | string | |
wrongCodeLabel | string | |
className | string |
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);
});
});
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
codeLengthto 4. The default subtitle updates to say how many digits to enter.
More in Forms and inputs
Buttons, fields, pickers and sign-in flows for any product.