One-Time Code New
A six-digit code input with paste support, auto-advance, arrow keys, a resend countdown, an error shake and a success state.
Installation
01
Install with the shadcn CLI. The NiceUI theme comes along the first time.
$ npx shadcn@latest add https://niceui.dev/r/application-otp-verify.json02
Or copy the source below (2 files). Dependencies: lucide-react.
Source
components/niceui/app/OtpVerify.tsx
import { useEffect, useRef, useState, type ClipboardEvent, type KeyboardEvent } from 'react';
import { CheckCircle2, ShieldCheck } from 'lucide-react';
import { cn } from '@/lib/cn';
import { AuthCard, PrimaryButton } from './authUi';
export type OtpVerifyProps = {
length?: number;
destination?: string;
/** Return true when the code is valid. */
onVerify?: (code: string) => Promise<boolean>;
resendSeconds?: number;
/** Pre-filled digits. */
initial?: string;
className?: string;
};
export function OtpVerify({ length = 6, destination = 'maya@halcyon.app', onVerify, resendSeconds = 30, initial = '', className }: OtpVerifyProps) {
const [digits, setDigits] = useState<string[]>(() => Array(length).fill('').map((_, k) => initial[k] ?? ''));
const [state, setState] = useState<'idle' | 'checking' | 'error' | 'done'>('idle');
const [left, setLeft] = useState(resendSeconds);
const refs = useRef<(HTMLInputElement | null)[]>([]);
useEffect(() => {
if (left <= 0) return;
const id = setTimeout(() => setLeft((s) => s - 1), 1000);
return () => clearTimeout(id);
}, [left]);
const verify = async (code: string) => {
setState('checking');
const ok = await (onVerify ?? ((c) => new Promise<boolean>((r) => setTimeout(() => r(c === '424242'), 900))))(code);
if (ok) setState('done');
else {
setState('error');
setTimeout(() => {
setDigits(Array(length).fill(''));
refs.current[0]?.focus();
}, 450);
}
};
const set = (i: number, v: string) => {
const next = [...digits];
next[i] = v;
setDigits(next);
if (state === 'error') setState('idle');
if (v && i < length - 1) refs.current[i + 1]?.focus();
if (next.every(Boolean)) verify(next.join(''));
};
const onKey = (i: number, e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Backspace' && !digits[i] && i > 0) {
refs.current[i - 1]?.focus();
const next = [...digits];
next[i - 1] = '';
setDigits(next);
}
if (e.key === 'ArrowLeft' && i > 0) refs.current[i - 1]?.focus();
if (e.key === 'ArrowRight' && i < length - 1) refs.current[i + 1]?.focus();
};
const onPaste = (e: ClipboardEvent) => {
const text = e.clipboardData.getData('text').replace(/\D/g, '').slice(0, length);
if (!text) return;
e.preventDefault();
const next = Array(length).fill('').map((_, k) => text[k] ?? '');
setDigits(next);
refs.current[Math.min(text.length, length - 1)]?.focus();
if (text.length === length) verify(text);
};
if (state === 'done')
return (
<AuthCard className={cn('text-center', className)}>
<CheckCircle2 className="mx-auto h-12 w-12 text-emerald-400 animate-scale-in" />
<h2 className="mt-4 text-xl font-semibold text-ink-50">You’re verified</h2>
<p className="mt-1 text-sm text-ink-400">Redirecting you to your workspace…</p>
<button onClick={() => { setDigits(Array(length).fill('')); setState('idle'); }} className="focus-ring mt-6 text-sm text-brand-300 hover:text-brand-200">
Run the demo again
</button>
</AuthCard>
);
return (
<AuthCard className={className}>
<span className="grid h-11 w-11 place-items-center rounded-xl bg-brand-500/10 text-brand-300 ring-1 ring-brand-400/25">
<ShieldCheck className="h-5 w-5" />
</span>
<h2 className="mt-5 text-xl font-semibold tracking-tight text-ink-50">Check your email</h2>
<p className="mt-1 text-sm text-ink-400">
We sent a {length}-digit code to <span className="text-ink-100">{destination}</span>.
</p>
<div className={cn('mt-6 flex justify-between gap-2', state === 'error' && 'animate-wiggle')} onPaste={onPaste} role="group" aria-label="One-time code">
{digits.map((d, i) => (
<input
key={i}
ref={(el) => (refs.current[i] = el)}
value={d}
inputMode="numeric"
autoComplete={i === 0 ? 'one-time-code' : 'off'}
maxLength={1}
aria-label={`Digit ${i + 1}`}
disabled={state === 'checking'}
onChange={(e) => set(i, e.target.value.replace(/\D/g, '').slice(-1))}
onKeyDown={(e) => onKey(i, e)}
onFocus={(e) => e.target.select()}
className={cn(
'h-12 w-full min-w-0 rounded-xl border bg-ink-950/60 text-center font-mono text-lg text-ink-50 outline-none transition-all focus:ring-2',
state === 'error' ? 'border-rose-500/70 focus:ring-rose-500/25' : d ? 'border-brand-400/50 focus:ring-brand-500/25' : 'border-white/10 focus:border-brand-400/60 focus:ring-brand-500/20',
)}
/>
))}
</div>
<p className={cn('mt-3 h-4 text-xs', state === 'error' ? 'text-rose-300' : 'text-ink-500')} aria-live="polite">
{state === 'error' ? 'That code isn’t right. Try again.' : state === 'checking' ? 'Checking…' : 'Tip: in this demo the code is 424242. Paste works too.'}
</p>
<PrimaryButton className="mt-5" loading={state === 'checking'} disabled={!digits.every(Boolean)} onClick={() => verify(digits.join(''))}>
Verify
</PrimaryButton>
<p className="mt-4 text-center text-sm text-ink-500">
Didn’t get it?{' '}
{left > 0 ? (
<span className="font-mono tabular-nums text-ink-400">Resend in 0:{String(left).padStart(2, '0')}</span>
) : (
<button onClick={() => setLeft(resendSeconds)} className="focus-ring text-brand-300 hover:text-brand-200">
Resend code
</button>
)}
</p>
</AuthCard>
);
}
export function OtpVerifyDemo() {
return <OtpVerify initial="424" />;
}components/niceui/app/authUi.tsx
import { useState, type InputHTMLAttributes, type ReactNode } from 'react';
import { Eye, EyeOff, Check, X } from 'lucide-react';
import { cn } from '@/lib/cn';
/** Shared bits for the auth screens. */
export function AuthCard({ children, className }: { children: ReactNode; className?: string }) {
return <div className={cn('w-full max-w-[400px] rounded-2xl bg-ink-900 p-6 shadow-card sm:p-7', className)}>{children}</div>;
}
export function BrandMark({ className }: { className?: string }) {
return (
<span className={cn('inline-flex items-center gap-2 text-sm font-semibold tracking-tight text-ink-50', className)}>
<span className="grid h-7 w-7 place-items-center rounded-lg bg-gradient-to-br from-brand-300 to-brand-600 shadow-glow">
<span className="h-2.5 w-2.5 rounded-full bg-ink-950" />
</span>
Halcyon
</span>
);
}
type FieldProps = InputHTMLAttributes<HTMLInputElement> & { label: string; error?: string; hint?: ReactNode; trailing?: ReactNode };
export function Field({ label, error, hint, trailing, id, className, ...rest }: FieldProps) {
const fid = id ?? `f-${label.toLowerCase().replace(/\W+/g, '-')}`;
return (
<div className={className}>
<div className="mb-1.5 flex items-center justify-between">
<label htmlFor={fid} className="text-xs font-medium text-ink-300">
{label}
</label>
{hint}
</div>
<div
className={cn(
'flex h-10 items-center rounded-lg border bg-ink-950/60 transition-colors focus-within:ring-2',
error ? 'border-rose-500/60 focus-within:ring-rose-500/25' : 'border-white/10 focus-within:border-brand-400/60 focus-within:ring-brand-500/20',
)}
>
<input id={fid} aria-invalid={!!error} aria-describedby={error ? `${fid}-err` : undefined} className="h-full min-w-0 flex-1 bg-transparent px-3 text-sm text-ink-50 outline-none placeholder:text-ink-600" {...rest} />
{trailing}
</div>
{error && (
<p id={`${fid}-err`} className="mt-1.5 text-xs text-rose-300 animate-fade-in">
{error}
</p>
)}
</div>
);
}
export function PasswordField(props: Omit<FieldProps, 'type' | 'trailing'>) {
const [show, setShow] = useState(false);
return (
<Field
{...props}
type={show ? 'text' : 'password'}
trailing={
<button type="button" onClick={() => setShow((s) => !s)} aria-label={show ? 'Hide password' : 'Show password'} className="focus-ring mr-1 grid h-8 w-8 place-items-center rounded-md text-ink-500 hover:text-ink-200">
{show ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
}
/>
);
}
export function PrimaryButton({ children, loading, done, className, ...rest }: React.ButtonHTMLAttributes<HTMLButtonElement> & { loading?: boolean; done?: boolean }) {
return (
<button
{...rest}
disabled={rest.disabled || loading}
className={cn(
'focus-ring relative flex h-10 w-full items-center justify-center gap-2 rounded-lg text-sm font-medium transition-all active:scale-[0.98] disabled:cursor-not-allowed',
done ? 'bg-emerald-500 text-ink-950' : 'bg-brand-400 text-ink-950 hover:bg-brand-300 disabled:opacity-70',
className,
)}
>
{loading ? <span className="h-4 w-4 animate-spin rounded-full border-2 border-ink-950/30 border-t-ink-950" /> : done ? <Check className="h-4 w-4 animate-scale-in" /> : null}
{children}
</button>
);
}
function Glyph({ letter, className }: { letter: string; className: string }) {
return <span className={cn('grid h-4 w-4 place-items-center rounded-[4px] text-[10px] font-bold', className)}>{letter}</span>;
}
export function SsoButtons() {
return (
<div className="grid grid-cols-2 gap-2">
{[
['Google', <Glyph key="g" letter="G" className="bg-white text-ink-950" />],
['GitHub', <Glyph key="h" letter="H" className="bg-ink-700 text-ink-50" />],
].map(([name, icon]) => (
<button key={name as string} type="button" className="focus-ring flex h-10 items-center justify-center gap-2 rounded-lg border border-white/10 bg-white/[0.03] text-sm text-ink-200 transition-colors hover:bg-white/[0.06]">
{icon}
<span className="truncate">{name as string}</span>
</button>
))}
</div>
);
}
export function Divider({ label = 'or' }: { label?: string }) {
return (
<div className="my-5 flex items-center gap-3 text-[11px] uppercase tracking-wider text-ink-600">
<span className="h-px flex-1 bg-white/10" />
{label}
<span className="h-px flex-1 bg-white/10" />
</div>
);
}
export function passwordChecks(pw: string) {
return [
{ label: 'At least 8 characters', ok: pw.length >= 8 },
{ label: 'An uppercase letter', ok: /[A-Z]/.test(pw) },
{ label: 'A number', ok: /\d/.test(pw) },
{ label: 'A symbol', ok: /[^A-Za-z0-9]/.test(pw) },
];
}
export function StrengthMeter({ password }: { password: string }) {
const checks = passwordChecks(password);
const score = checks.filter((c) => c.ok).length;
const label = ['Too weak', 'Weak', 'Fair', 'Good', 'Strong'][score];
const color = ['bg-ink-700', 'bg-rose-400', 'bg-amber-400', 'bg-brand-400', 'bg-emerald-400'][score];
return (
<div className="space-y-2.5">
<div className="flex items-center gap-2">
<div className="flex flex-1 gap-1">
{[0, 1, 2, 3].map((i) => (
<span key={i} className={cn('h-1 flex-1 rounded-full transition-colors duration-300', i < score ? color : 'bg-white/10')} />
))}
</div>
<span className="w-16 text-right text-[11px] text-ink-400" aria-live="polite">
{password ? label : ''}
</span>
</div>
<ul className="grid grid-cols-2 gap-x-3 gap-y-1">
{checks.map((c) => (
<li key={c.label} className={cn('flex items-center gap-1.5 text-[11px] transition-colors', c.ok ? 'text-emerald-300' : 'text-ink-500')}>
{c.ok ? <Check className="h-3 w-3 shrink-0" /> : <X className="h-3 w-3 shrink-0" />}
<span className="truncate">{c.label}</span>
</li>
))}
</ul>
</div>
);
}