Lunia DS

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.

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-input

Usage

The demo above, as code.

demo.tsx
'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).

PropTypeDefaultDescription
lengthnumber6Number of boxes.
valuestringundefinedControlled value.
defaultValuestring""Starting value when uncontrolled.
resendAfternumber30Seconds before a new code can be sent. The clock starts on mount and after each resend.
labelstring"Enter your 6-digit code"Label above the boxes; the number follows length.
groupsnumber[]undefinedBox groups with a dash between them, for example [3, 3].
alphanumericbooleanfalseAccept letters as well as digits (letters become upper case).
autoFocusbooleanfalseFocus the input on mount.
EventDescription
valueChangeFires with the new value after each change.
completeFires once when every box is full. Return a promise; resolve or return true to accept, reject or return false to show Wrong.
resendFires 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.

IDTriggerElementPropertyFrom → toTimingReduced motion
CI1focus, or move to the next boxactive boxbox-shadow (inner ring)1px border-primary → 1.5px border-brandduration-lg (320)ease-outSwaps at once
CI2active box is emptycaretopacity blink (on, off)1 → 0, repeatsduration-6xl (900)ease-linearNo loop
CI3type a characterglyphopacity + transform translateY + filter blur0, 45%, blur-text → 1, 0, 0duration-lg (320)ease-outShows at once
CI4paste or autofill several charactersglyphsas CI3, one box after another0, 45%, blur-text → 1, 0, 0duration-lg (320)ease-outdelay stagger-step x boxShows at once
CI5delete a characterglyphopacity + transform translateY + filter blur1, 0, 0 → 0, -35%, blur-textduration-sm (200)ease-outHides at once
CI6every state changelabeltext morphprevious label → "Checking code", "This code is not correct" or "Verified"duration-3xl (460)ease-outSwaps at once
CI7last box filled (Typing -> Checking)box rowmask-position sheen150% → -50%, repeatsduration-loop (1600)ease-linearNo loop
CI8code rejected (Checking -> Wrong)box rowtransform translateX shake0 → -5px, 5px, -3px, 0duration-xl (380)ease-exitStays still
CI9code rejected (Checking -> Wrong)boxes + labelbox-shadow ring color + label colorborder-primary, text-secondary → border-error_subtle, text-error-primaryduration-lg (320)ease-outSwaps at once
CI10650ms after Wrongglyphsas CI5, clearing from the right1, 0, 0 → 0, -35%, blur-textduration-sm (200)ease-outdelay 650 + stagger-list x boxHides at once
CI11code accepted (Checking -> Verified)box rowbox-shadow ring + background-color (one pill)transparent → 1px fg-success-secondary, bg-success-primaryduration-4xl (520)ease-outSwaps at once
CI12code accepted (Checking -> Verified)boxesbox-shadow ring + background-colorborder-primary, bg-primary → transparentduration-lg (320)ease-outHides at once
CI13code accepted (Checking -> Verified)group dashopacity (keeps its place, so digits do not move)1 → 0duration-lg (320)ease-outHides at once
CI14code accepted (Checking -> Verified)check columngrid-template-columns + opacity0fr, 0 → 1fr, 1duration-4xl (520)ease-outResizes at once
CI15code accepted (Checking -> Verified)checkstroke-dashoffset (draw)1 → 0duration-2xl (420)ease-outdelay 260msShows at once
CI16each second while Countingcountdown secondsnumber roll (down)n → n - 1duration-5xl (640)ease-outSwaps at once
CI17countdown reaches 0 (Counting -> Ready), or resend done (Ready -> Counting)countdown <-> resend buttonopacity + filter blur crossfade1, 0 → 0, blur-swapduration-md (260)ease-outSwaps at once
CI18press resendresend buttonstatus button motion (Sending, Sent, Try again); back to Counting 1100ms after SentIdle → Sentduration-xl (380)ease-outSwaps 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.