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.
Live preview
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-fieldUsage
The demo above, as code.
'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).
| Prop | Type | Default | Description |
|---|---|---|---|
| joined | { position?: number } | null | null | Show the joined state at once, for someone who is already on the list. |
| placeholder | string | "name@company.com" | Placeholder of the email input. |
| labels | Partial<{ 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. |
| Event | Description |
|---|---|
| submit | Called 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.
| ID | Trigger | Element | Property | From → to | Timing | Reduced motion |
|---|---|---|---|---|---|---|
| WF1 | focus the input | pill | box-shadow (1px inner ring) | border-primary → text-primary at 30% | duration-lg (320)ease-out | Swaps at once |
| WF2 | press the button | submit button | transform scale | 1 → scale-press | duration-2xs (120)ease-exit | Stays still |
| WF3 | submit with an invalid email (Idle -> Invalid) | pill | transform translateX shake | 0 → -5px, 5px, -2px, 0 | duration-xl (380)ease-exit | Stays still |
| WF4 | submit with an invalid email (Idle -> Invalid) | pill | box-shadow (1px inner ring) | border-primary → border-error | duration-lg (320)ease-out | Swaps at once |
| WF5 | submit with an invalid email (Idle -> Invalid) | hint line | opacity + transform translateY | 0, -3px → 1, 0 | duration-lg (320)ease-out | Shows at once |
| WF6 | type after Invalid (Invalid -> Idle) | hint line + pill ring | reverse of WF4 and WF5 | error → rest | duration-lg (320)ease-out | Hides at once |
| WF7 | submit a valid email (Idle -> Joining) | submit button | width | rest width (measured from the label) → full pill width - 8px | duration-4xl (520)ease-out | Resizes at once |
| WF8 | submit a valid email (Idle -> Joining) | email input | opacity | 1 → 0 | duration-sm (200)ease-exit | Hides at once |
| WF9 | every state change of the button | label | text morph | previous label → next label | duration-3xl (460)ease-out | Swaps at once |
| WF10 | while Joining | loading sweep (half-width light band) | transform translateX | -100% → 250%, repeats | duration-loop (1600)ease-loop | No loop |
| WF11 | request resolves (Joining -> Joined) | check slot | width | 0 → 16px | duration-xl (380)ease-out | Resizes at once |
| WF12 | request resolves (Joining -> Joined) | check | stroke-dashoffset (draw) | 1 → 0 | duration-3xl (460)ease-outdelay 260ms | Shows at once |
| WF13 | request resolves with a position | position | opacity | 0 → 1 | duration-lg (320)ease-exitdelay 200ms | Shows at once |
| WF14 | request resolves with a position | position number | number roll | position - 36 → position | duration-6xl (900)ease-outdelay 200ms | Swaps at once |
| WF15 | request fails (Joining -> Failed) | submit button | width | full pill width - 8px → rest width | duration-4xl (520)ease-out | Resizes at once |
| WF16 | request fails (Joining -> Failed) | pill | transform translateX shake (as WF3) | 0 → -5px, 5px, -2px, 0 | duration-xl (380)ease-exit | Stays still |
| WF17 | request fails (Joining -> Failed) | email input | opacity | 0 → 1 | duration-sm (200)ease-exit | Shows 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.