Lunia DS

Actions and inputs

Waitlist field

An email field with a join button that stretches over the field while the request runs and then turns into the result, with a drawn check and your place in line.

Loading preview

A centered launch card: title "Lunia is coming", one line of copy, and the waitlist field. The demo request waits 1200ms and resolves with position 1,248; an email that ends in "@fail.test" shows Failed. Replay types "maya@studio.io" into the field, submits and runs to Joined.

Installation

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

$ npx @ganisigit/lunia-ds add waitlist-field

Usage

The demo above, as code.

demo.tsx
'use client';

import * as React from 'react';
import { WaitlistField } from './waitlist-field';

// How long the pretend request takes (ms), and the address the replay types.
const WORK = 1200;
const SAMPLE = 'maya@studio.io';

function join(email: string) {
  return new Promise<{ position: number }>((resolve, reject) => {
    window.setTimeout(() => {
      if (email.endsWith('@fail.test')) reject(new Error('failed'));
      else resolve({ position: 1248 });
    }, WORK);
  });
}

/** Types into an input the way a person 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 WaitlistFieldDemo({ 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 field = () => card.current?.querySelector('form');
    for (let i = 1; i <= SAMPLE.length; i++) {
      at(300 + i * 60, () => {
        const input = field()?.querySelector('input');
        if (input) typeInto(input, SAMPLE.slice(0, i));
      });
    }
    at(300 + SAMPLE.length * 60 + 400, () => field()?.requestSubmit());
  }, [replay]);

  React.useEffect(
    () => () => {
      for (const timer of timers.current) window.clearTimeout(timer);
    },
    [],
  );

  return (
    <div
      ref={card}
      className="flex w-full max-w-[480px] flex-col items-center gap-6 rounded-2xl border border-secondary bg-primary px-10 pt-10 pb-5 text-center shadow-sm"
    >
      <div className="flex flex-col gap-2">
        <h3 className="text-xl font-semibold text-primary">Lunia is coming</h3>
        <p className="text-md text-tertiary">
          Join the waitlist and we will email you when your invite is ready.
        </p>
      </div>
      <WaitlistField key={run} onSubmit={join} className="text-start" />
    </div>
  );
}

API

Parts: form, pill (field frame), email input, visually hidden label, submit button (stretching surface), loading sweep, check slot, label (text morph), position (separator dot + number roll), hint line, status (live region, screen reader only).

PropTypeDefaultDescription
joined{ position?: number } | nullnullShow the joined state at once, for someone who is already on the list.
placeholderstring"name@company.com"Placeholder of the email input.
labelsPartial<{ idle: string; pending: string; success: string; error: string; invalid: string }>{ idle: "Join waitlist", pending: "Joining", success: "Added to the waitlist", error: "Try again", invalid: "Enter a valid email address." }Button labels for each state and the hint for an invalid email.
EventDescription
submitCalled with the email after it passes the format check. Return a promise; resolve with { position } to show the place in line, reject to show Failed.

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
WF1focus the inputpillbox-shadow (1px inner ring)border-primary → text-primary at 30%duration-lg (320)ease-outSwaps at once
WF2press the buttonsubmit buttontransform scale1 → scale-pressduration-2xs (120)ease-exitStays still
WF3submit with an invalid email (Idle -> Invalid)pilltransform translateX shake0 → -5px, 5px, -2px, 0duration-xl (380)ease-exitStays still
WF4submit with an invalid email (Idle -> Invalid)pillbox-shadow (1px inner ring)border-primary → border-errorduration-lg (320)ease-outSwaps at once
WF5submit with an invalid email (Idle -> Invalid)hint lineopacity + transform translateY0, -3px → 1, 0duration-lg (320)ease-outShows at once
WF6type after Invalid (Invalid -> Idle)hint line + pill ringreverse of WF4 and WF5error → restduration-lg (320)ease-outHides at once
WF7submit a valid email (Idle -> Joining)submit buttonwidthrest width (measured from the label) → full pill width - 8pxduration-4xl (520)ease-outResizes at once
WF8submit a valid email (Idle -> Joining)email inputopacity1 → 0duration-sm (200)ease-exitHides at once
WF9every state change of the buttonlabeltext morphprevious label → next labelduration-3xl (460)ease-outSwaps at once
WF10while Joiningloading sweep (half-width light band)transform translateX-100% → 250%, repeatsduration-loop (1600)ease-loopNo loop
WF11request resolves (Joining -> Joined)check slotwidth0 → 16pxduration-xl (380)ease-outResizes at once
WF12request resolves (Joining -> Joined)checkstroke-dashoffset (draw)1 → 0duration-3xl (460)ease-outdelay 260msShows at once
WF13request resolves with a positionpositionopacity0 → 1duration-lg (320)ease-exitdelay 200msShows at once
WF14request resolves with a positionposition numbernumber rollposition - 36 → positionduration-6xl (900)ease-outdelay 200msSwaps at once
WF15request fails (Joining -> Failed)submit buttonwidthfull pill width - 8px → rest widthduration-4xl (520)ease-outResizes at once
WF16request fails (Joining -> Failed)pilltransform translateX shake (as WF3)0 → -5px, 5px, -2px, 0duration-xl (380)ease-exitStays still
WF17request fails (Joining -> Failed)email inputopacity0 → 1duration-sm (200)ease-exitShows at once

Accessibility

  • A real form with a native email input (type email, inputmode email, autocomplete email) and a visually hidden label "Email address".
  • Enter submits; the submit button has aria-disabled while Joining and Joined, and the input is disabled so it cannot be edited.
  • An invalid email sets aria-invalid, links the hint with aria-describedby and moves focus back to the input.
  • A polite live region (role status) reads "Added to the waitlist, number 1,248" on success and "Could not join the waitlist. Try again." on failure.
  • Focus ring on the button (focus-ring token); the pill is 48px tall and the button 40px, above the 24px minimum target.
  • Reduced motion: the button resizes and labels swap at once, the check shows at once, there is no shake and no loading sweep.