Lunia DS

Base components

Verification code

One box per digit for a 4 or 6 digit code, where each digit rises into its box as it is typed, focus moves to the next box, and a pasted code fills the boxes one after another.

Loading preview

A 6 digit code at size md with the hint "Enter the code from your email." and a Send again link, a 4 digit code at size sm, and an invalid state. Replay pastes 482913 so the digits fill one after another, then clears them.

Installation

Run this in your app after the one-time setup on the Getting started page.

$ npx @ganisigit/lunia-ds add verification-code

Usage

The demo above, as code.

demo.tsx
'use client';

import * as React from 'react';
import { Button } from '../button/button';
import { VerificationCode } from './verification-code';

const PASTED = '482913';
const HOLD_MS = 1600;

export default function VerificationCodeDemo({ replay = 0 }: { replay?: number }) {
  const [code, setCode] = React.useState('');

  React.useEffect(() => {
    if (replay === 0) return;
    // Pastes a whole code at once, so the digits fill one box after another, then clears it.
    setCode(PASTED);
    const timer = window.setTimeout(() => setCode(''), HOLD_MS);
    return () => window.clearTimeout(timer);
  }, [replay]);

  return (
    <div className="flex flex-col items-center gap-10">
      <div className="flex flex-col items-start gap-3">
        <VerificationCode value={code} onValueChange={setCode} hint="Enter the code from your email." />
        <Button hierarchy="link-color" size="sm" onClick={() => setCode('')}>
          Send again
        </Button>
      </div>
      <div className="flex flex-wrap items-start justify-center gap-10">
        <VerificationCode length={4} size="sm" label="Sign-in code" />
        <VerificationCode
          length={4}
          size="sm"
          label="Card code"
          defaultValue="1234"
          invalid
          hint="This code is not correct. Check the code and try again."
        />
      </div>
    </div>
  );
}

API

Parts: root (field), label, digit boxes (4 or 6), separator dash (after the third box of 6), caret (in the focused empty box), hint text (becomes the error text when invalid).

PropTypeDefaultDescription
length4 | 66Number of digit boxes.
valuestringundefinedControlled code.
defaultValuestring""Code when uncontrolled.
size"sm" | "md" | "lg""md"Boxes of 64, 80 or 96px.
labelstring"Verification code"Label above the boxes.
hintstringundefinedHelp line under the boxes. When invalid, pass the error text here.
invalidbooleanfalseError border and error digits in every box, and a red hint.
disabledbooleanfalseTurns every box off.
autoFocusbooleanfalseFocuses the first empty box on mount.
namestringundefinedForm field name; the hidden input holds the whole code.
EventDescription
valueChangeThe code changed. Payload: the new code.
completeEvery box has a digit. Payload: the full code.
SlotDescription
hintRich hint content, for example a Send again link.

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
VC1hover a boxbox borderborder-colorborder-control → border-control_hoverduration-sm (200)ease-outSwaps at once
VC2focus moves to a boxboxborder-color, box-shadowborder-control, shadow-xs → border-brand, focus-ring-shadow-xsduration-2xs (120)ease-outShows at once
VC3a digit is typeddigitopacity, translate y0, distance-rise → 1, 0duration-sm (200)ease-outShows at once
VC4a digit is typedbox borderborder-colorborder-control → border-brandduration-sm (200)ease-outSwaps at once
VC5a digit is deleteddigitopacity1 → 0duration-2xs (120)ease-exitHides at once
VC6a code is pasted or filled by the browserdigitsopacity, translate y (as VC3), one box after another0, distance-rise → 1, 0duration-sm (200)ease-outdelay stagger-list x boxShows at once
VC7while a box is focused and emptycaretopacity1 → 0, repeatsduration-6xl (900)ease-linearNo loop
VC8invalid -> truebox borders, digits, hintborder-color, colorborder-brand, text-brand-tertiary_alt, text-tertiary → border-error, text-error-primary, text-error-primaryduration-sm (200)ease-outSwaps at once

Accessibility

  • Built on a one-time code primitive: one input per box with inputmode numeric and autocomplete one-time-code on the first box, so phones can fill the code from a message.
  • Each box is named "Digit 1 of 6" and the group is labelled by the label; the hint is linked with aria-describedby.
  • Typing moves focus to the next box, Backspace clears and moves back, Left and Right move between boxes, and paste fills from the focused box.
  • Placeholder digits (a faded 0) are aria-hidden.
  • invalid sets aria-invalid on every box; the hint says what to do, for example "This code is not correct. Check the code and try again."
  • No shake on error: the error colors and the hint carry it, so nothing moves under the pointer.
  • Reduced motion: digits show at once and the caret does not blink.