Actions and inputs
Code input
A one-time code field drawn as separate boxes, where digits rise in as you type, the row shakes and clears on a wrong code, and the boxes join into one pill with a check when the code is right.
Live preview
Loading preview
A sign-in card "Check your email" with a 6-box code input in groups of 3 and 3 and the resend line with a 30 second clock. The demo accepts 424242 after 900ms and rejects any other code. Replay clears the field, types 1 2 3 4 5 6 (rejected, shake, clear), then pastes 424242 (accepted).
Installation
Run this in your app after the one-time setup on the Getting started page.
$ npx @ganisigit/lunia-ds add code-inputUsage
The demo above, as code.
'use client';
import * as React from 'react';
import { CodeInput } from './code-input';
// The pretend server accepts this code after a short wait (ms).
const RIGHT = '424242';
const CHECK = 900;
function verify(code: string) {
return new Promise<boolean>((resolve) => window.setTimeout(() => resolve(code === RIGHT), CHECK));
}
function resend() {
return new Promise<void>((resolve) => window.setTimeout(resolve, 800));
}
/** Changes the input the way typing or pasting would, so the component sees normal input events. */
function typeInto(input: HTMLInputElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
setter?.call(input, value);
input.dispatchEvent(new Event('input', { bubbles: true }));
}
export default function CodeInputDemo({ replay = 0 }: { replay?: number }) {
const [run, setRun] = React.useState(0);
const card = React.useRef<HTMLDivElement>(null);
const timers = React.useRef<number[]>([]);
React.useEffect(() => {
if (replay === 0) return;
for (const timer of timers.current) window.clearTimeout(timer);
timers.current = [];
setRun((n) => n + 1);
const at = (ms: number, step: () => void) => timers.current.push(window.setTimeout(step, ms));
const input = () => card.current?.querySelector('input');
at(250, () => input()?.focus());
// Type a wrong code one digit at a time, then paste the right one once the boxes have cleared.
for (let i = 1; i <= 6; i++) {
at(300 + i * 150, () => {
const el = input();
if (el) typeInto(el, '123456'.slice(0, i));
});
}
at(3600, () => {
const el = input();
if (el) typeInto(el, RIGHT);
});
}, [replay]);
React.useEffect(
() => () => {
for (const timer of timers.current) window.clearTimeout(timer);
},
[],
);
return (
<div
ref={card}
className="flex w-full max-w-[400px] flex-col gap-6 rounded-2xl border border-secondary bg-primary p-8 shadow-sm"
>
<div className="flex flex-col gap-1">
<h3 className="text-lg font-semibold text-primary">Check your email</h3>
<p className="text-sm text-tertiary">We sent a 6-digit code to maya@studio.io.</p>
</div>
<CodeInput key={run} groups={[3, 3]} onComplete={verify} onResend={resend} />
</div>
);
}
API
Parts: root, label (text morph), hidden real input (covers the boxes), box row, box, glyph, caret, group dash, check column, resend line (prompt + countdown or resend button), status (live region, screen reader only).
| Prop | Type | Default | Description |
|---|---|---|---|
| length | number | 6 | Number of boxes. |
| value | string | undefined | Controlled value. |
| defaultValue | string | "" | Starting value when uncontrolled. |
| resendAfter | number | 30 | Seconds before a new code can be sent. The clock starts on mount and after each resend. |
| label | string | "Enter your 6-digit code" | Label above the boxes; the number follows length. |
| groups | number[] | undefined | Box groups with a dash between them, for example [3, 3]. |
| alphanumeric | boolean | false | Accept letters as well as digits (letters become upper case). |
| autoFocus | boolean | false | Focus the input on mount. |
| Event | Description |
|---|---|
| valueChange | Fires with the new value after each change. |
| complete | Fires once when every box is full. Return a promise; resolve or return true to accept, reject or return false to show Wrong. |
| resend | Fires when the resend button is pressed; the resend line appears only when this is handled. Return a promise to show the button's Sending state. |
Motion
Every row names a token from the Motion page. The last column says what happens when the user asks for reduced motion.
| ID | Trigger | Element | Property | From → to | Timing | Reduced motion |
|---|---|---|---|---|---|---|
| CI1 | focus, or move to the next box | active box | box-shadow (inner ring) | 1px border-primary → 1.5px border-brand | duration-lg (320)ease-out | Swaps at once |
| CI2 | active box is empty | caret | opacity blink (on, off) | 1 → 0, repeats | duration-6xl (900)ease-linear | No loop |
| CI3 | type a character | glyph | opacity + transform translateY + filter blur | 0, 45%, blur-text → 1, 0, 0 | duration-lg (320)ease-out | Shows at once |
| CI4 | paste or autofill several characters | glyphs | as CI3, one box after another | 0, 45%, blur-text → 1, 0, 0 | duration-lg (320)ease-outdelay stagger-step x box | Shows at once |
| CI5 | delete a character | glyph | opacity + transform translateY + filter blur | 1, 0, 0 → 0, -35%, blur-text | duration-sm (200)ease-out | Hides at once |
| CI6 | every state change | label | text morph | previous label → "Checking code", "This code is not correct" or "Verified" | duration-3xl (460)ease-out | Swaps at once |
| CI7 | last box filled (Typing -> Checking) | box row | mask-position sheen | 150% → -50%, repeats | duration-loop (1600)ease-linear | No loop |
| CI8 | code rejected (Checking -> Wrong) | box row | transform translateX shake | 0 → -5px, 5px, -3px, 0 | duration-xl (380)ease-exit | Stays still |
| CI9 | code rejected (Checking -> Wrong) | boxes + label | box-shadow ring color + label color | border-primary, text-secondary → border-error_subtle, text-error-primary | duration-lg (320)ease-out | Swaps at once |
| CI10 | 650ms after Wrong | glyphs | as CI5, clearing from the right | 1, 0, 0 → 0, -35%, blur-text | duration-sm (200)ease-outdelay 650 + stagger-list x box | Hides at once |
| CI11 | code accepted (Checking -> Verified) | box row | box-shadow ring + background-color (one pill) | transparent → 1px fg-success-secondary, bg-success-primary | duration-4xl (520)ease-out | Swaps at once |
| CI12 | code accepted (Checking -> Verified) | boxes | box-shadow ring + background-color | border-primary, bg-primary → transparent | duration-lg (320)ease-out | Hides at once |
| CI13 | code accepted (Checking -> Verified) | group dash | opacity (keeps its place, so digits do not move) | 1 → 0 | duration-lg (320)ease-out | Hides at once |
| CI14 | code accepted (Checking -> Verified) | check column | grid-template-columns + opacity | 0fr, 0 → 1fr, 1 | duration-4xl (520)ease-out | Resizes at once |
| CI15 | code accepted (Checking -> Verified) | check | stroke-dashoffset (draw) | 1 → 0 | duration-2xl (420)ease-outdelay 260ms | Shows at once |
| CI16 | each second while Counting | countdown seconds | number roll (down) | n → n - 1 | duration-5xl (640)ease-out | Swaps at once |
| CI17 | countdown reaches 0 (Counting -> Ready), or resend done (Ready -> Counting) | countdown <-> resend button | opacity + filter blur crossfade | 1, 0 → 0, blur-swap | duration-md (260)ease-out | Swaps at once |
| CI18 | press resend | resend button | status button motion (Sending, Sent, Try again); back to Counting 1100ms after Sent | Idle → Sent | duration-xl (380)ease-out | Swaps at once |
Accessibility
- One real input sits over the boxes: autocomplete one-time-code, inputmode numeric (text when alphanumeric), pattern and maxlength, so paste, autofill and the number keyboard all work; the boxes are aria-hidden.
- The input has a real label (the morphing label above the boxes); the caret always stays at the end.
- aria-invalid on the input while Wrong; the input is read only while Checking and Verified.
- A polite live region reads "Checking code", "Code verified" or "This code is not correct. Enter it again."
- The countdown digits are hidden from screen readers; the live text says "You can resend the code soon." instead.
- Boxes are 34 by 40px (36 by 44px on wider containers); the resend button is 28px tall; all above the 24px minimum target.
- Reduced motion: glyphs show and hide at once, no caret blink, no sheen, no shake; the pill and the check show at once.