Actions and inputs
Steps form
A multi-step form in one card, where a segmented bar fills, the next step slides in from the side you move to, the card eases to the new height, and the card turns into a done message after submit.
Live preview
Loading preview
A 3-step "Create a workspace" form: Name (workspace name, required), Plan (two radio cards, Free and Team), Invite (email list, optional). The Name check waits 600ms and rejects an empty name with "Enter a workspace name." Submit waits 1000ms, then the card shows "Your workspace is ready" with a link button. Replay resets to step 1 and scripts: Continue with an empty name (error), type "Northwind", Continue, pick Team, Continue, Submit.
Installation
Run this in your app after the one-time setup on the Getting started page.
$ npx @ganisigit/lunia-ds add steps-formUsage
The demo above, as code.
'use client';
import * as React from 'react';
import { Button } from '../button/button';
import { Input } from '../input/input';
import { RadioGroup, RadioGroupItem } from '../radio-group/radio-group';
import { type FormStep, StepsForm } from './steps-form';
// How long the pretend checks take (ms).
const CHECK = 600;
const SUBMIT = 1000;
const wait = (ms: number) => new Promise((resolve) => window.setTimeout(resolve, ms));
/** Changes an input the way typing 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 }));
}
function DoneIcon() {
return (
<span className="flex size-10 items-center justify-center rounded-full bg-success-secondary text-fg-success-primary">
<svg aria-hidden="true" viewBox="0 0 20 20" fill="none" className="size-5">
<path
d="m6.25 10 2.5 2.5 5-5m4.583 2.5a8.333 8.333 0 1 1-16.666 0 8.333 8.333 0 0 1 16.666 0Z"
stroke="currentColor"
strokeWidth="1.67"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
);
}
function PlanName({ name, price }: { name: string; price: string }) {
return (
<>
{name} <span className="font-normal text-tertiary">{price}</span>
</>
);
}
function Workspace({ replay }: { replay: number }) {
const [name, setName] = React.useState('');
const [nameMissing, setNameMissing] = React.useState(false);
const [plan, setPlan] = React.useState('free');
const [invite, setInvite] = React.useState('');
const card = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (replay === 0) return;
const timers: number[] = [];
const at = (ms: number, step: () => void) => timers.push(window.setTimeout(step, ms));
const form = () => card.current?.querySelector('form');
const field = () => form()?.querySelector<HTMLInputElement>('[data-slot="step"] input');
at(400, () => form()?.requestSubmit());
at(1700, () => {
const el = field();
if (el) typeInto(el, 'Northwind');
});
at(2400, () => form()?.requestSubmit());
at(3800, () => form()?.querySelectorAll<HTMLElement>('[data-slot="step"] [role="radio"]')[1]?.click());
at(4500, () => form()?.requestSubmit());
at(5600, () => form()?.requestSubmit());
return () => {
for (const timer of timers) window.clearTimeout(timer);
};
}, [replay]);
const steps: FormStep[] = [
{
id: 'name',
label: 'Name',
title: 'Name your workspace',
description: 'You can change the name later.',
content: (
<Input
size="sm"
label="Workspace name"
placeholder="For example, Northwind"
value={name}
invalid={nameMissing}
onValueChange={(next) => {
setName(next);
setNameMissing(false);
}}
/>
),
validate: async () => {
await wait(CHECK);
setNameMissing(!name.trim());
return name.trim() ? null : 'Enter a workspace name.';
},
},
{
id: 'plan',
label: 'Plan',
title: 'Choose a plan',
description: 'You can change your plan at any time.',
content: (
<RadioGroup type="radio-button" aria-label="Plan" value={plan} onValueChange={setPlan}>
<RadioGroupItem
value="free"
label={<PlanName name="Free" price="$0" />}
supportingText="For one person and up to 3 projects."
/>
<RadioGroupItem
value="team"
label={<PlanName name="Team" price="$12 per user" />}
supportingText="Shared projects and roles for the whole team."
/>
</RadioGroup>
),
},
{
id: 'invite',
label: 'Invite',
title: 'Invite your team',
description: 'This step is optional. You can invite people later.',
content: (
<Input
size="sm"
type="email"
label="Email"
placeholder="name@company.com"
value={invite}
onValueChange={setInvite}
/>
),
},
];
return (
<div ref={card} className="flex w-full justify-center">
<StepsForm
steps={steps}
onSubmit={() => wait(SUBMIT)}
done={
<div className="flex flex-col items-start gap-4">
<DoneIcon />
<div className="flex flex-col gap-1">
<p className="text-lg font-semibold text-primary">Your workspace is ready</p>
<p className="text-sm text-tertiary">
{name.trim() || 'Your workspace'} is set up. You can invite more people at any time.
</p>
</div>
<Button size="sm" hierarchy="secondary">
Open workspace
</Button>
</div>
}
/>
</div>
);
}
export default function StepsFormDemo({ replay = 0 }: { replay?: number }) {
// A new key starts the form again at step 1 for every replay.
return <Workspace key={replay} replay={replay} />;
}
API
Parts: form (card), header (folds away when done), step name (text morph), step counter (number roll + total), progress segments, step title (text morph), panel (height + slide), step description, step content, error line (fold, role alert), footer (folds away when done), back button (folds in after step 1), continue or submit button (status button), done content.
| Prop | Type | Default | Description |
|---|---|---|---|
| steps | FormStep[] | - | Steps in order: { id, label, title, description?, content, validate? }. validate returns an error message to stop on the step, or nothing to continue; it can be async. Required. |
| submitLabels | Partial<Record<"idle" | "pending" | "success", string>> | { idle: "Submit", pending: "Sending", success: "Sent" } | Labels of the button on the last step. |
| Event | Description |
|---|---|
| submit | Runs after the last step passes its check. Return a promise; throw an Error with a message to stay on the step and show the message. |
| stepChange | Fires with the new step index. |
| Slot | Description |
|---|---|
| done | What the card shows after a successful submit. |
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 |
|---|---|---|---|---|---|---|
| SF1 | hover Back | back button | background-color, color | transparent, text-tertiary → bg-primary_hover, text-primary | duration-xs (160)ease-out | Swaps at once |
| SF2 | Continue or Back (step changes) | panel | height | old step height → new step height | duration-2xl (420)ease-out | Resizes at once |
| SF3 | Continue or Back (step changes) | old step (copy kept for the exit) | opacity + transform translateX + filter blur | 1, 0, 0 → 0, -distance-slide x direction, blur-text | duration-md (260)ease-out | Hides at once |
| SF4 | Continue or Back (step changes) | new step | opacity + transform translateX + filter blur | 0, distance-slide x direction, blur-text → 1, 0, 0 | duration-2xl (420)ease-outdelay 60ms | Shows at once |
| SF5 | Continue (forward) | segment of the new step | transform scaleX (from the left) | 0 → 1 | duration-4xl (520)ease-outdelay 80ms | Resizes at once |
| SF6 | Back | segment of the step you leave | transform scaleX | 1 → 0 | duration-4xl (520)ease-out | Resizes at once |
| SF7 | step changes | step name + step title | text morph | old text → new text | duration-3xl (460)ease-out | Swaps at once |
| SF8 | step changes | step counter | number roll | n → n +/- 1 | duration-3xl (460)ease-out | Swaps at once |
| SF9 | leave step 1, or go back to it | back button column | grid-template-columns | 0fr → 1fr | duration-xl (380)ease-out | Resizes at once |
| SF10 | Continue with an async check | continue button | status button motion (spinner in, label "Checking") | Idle → Checking | duration-xl (380)ease-out | Swaps at once |
| SF11 | check or submit fails (Error) | panel and error line | transform translateX shake | 0 → -6px, 5px, -3px, 2px, 0 | duration-xl (380)ease-exit | Stays still |
| SF12 | check or submit fails (Error) | error line | grid-template-rows (fold open) | 0fr → 1fr | duration-lg (320)ease-out | Resizes at once |
| SF13 | edit a field after Error | error line | grid-template-rows (fold shut) | 1fr → 0fr | duration-lg (320)ease-out | Resizes at once |
| SF14 | Submit on the last step | submit button | status button motion (Sending, then check draws, Sent) | Idle → Sent | duration-xl (380)ease-out | Swaps at once |
| SF15 | 750ms after Sent (Done) | header + footer | grid-template-rows (fold away) | 1fr → 0fr | duration-2xl (420)ease-out | Resizes at once |
| SF16 | 750ms after Sent (Done) | panel | as SF2 to SF4, last step out and done content in, moving forward | last step → done content | duration-2xl (420)ease-outdelay 60ms | Swaps at once |
Accessibility
- A real form labelled by the step title (aria-labelledby); Enter continues, because Continue is the submit button.
- When the step changes, focus moves to the first field of the new step (or the element with data-autofocus); the first load does not take focus.
- The error line has role alert, so the message is read at once; it folds away when the user edits a field.
- The counter and segments are aria-hidden; the step title carries the meaning.
- Back is removed from the tab order and aria-hidden on step 1 while it is folded.
- Buttons are 36px tall (Back is Button sm, next to the 36px Status button), above the 24px minimum target; visible focus ring on both.
- Reduced motion: no slide, blur or shake; height, folds and segments change at once and text swaps at once.