Lunia DS

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.

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

Usage

The demo above, as code.

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

PropTypeDefaultDescription
stepsFormStep[]-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.
submitLabelsPartial<Record<"idle" | "pending" | "success", string>>{ idle: "Submit", pending: "Sending", success: "Sent" }Labels of the button on the last step.
EventDescription
submitRuns 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.
stepChangeFires with the new step index.
SlotDescription
doneWhat 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.

IDTriggerElementPropertyFrom → toTimingReduced motion
SF1hover Backback buttonbackground-color, colortransparent, text-tertiary → bg-primary_hover, text-primaryduration-xs (160)ease-outSwaps at once
SF2Continue or Back (step changes)panelheightold step height → new step heightduration-2xl (420)ease-outResizes at once
SF3Continue or Back (step changes)old step (copy kept for the exit)opacity + transform translateX + filter blur1, 0, 0 → 0, -distance-slide x direction, blur-textduration-md (260)ease-outHides at once
SF4Continue or Back (step changes)new stepopacity + transform translateX + filter blur0, distance-slide x direction, blur-text → 1, 0, 0duration-2xl (420)ease-outdelay 60msShows at once
SF5Continue (forward)segment of the new steptransform scaleX (from the left)0 → 1duration-4xl (520)ease-outdelay 80msResizes at once
SF6Backsegment of the step you leavetransform scaleX1 → 0duration-4xl (520)ease-outResizes at once
SF7step changesstep name + step titletext morphold text → new textduration-3xl (460)ease-outSwaps at once
SF8step changesstep counternumber rolln → n +/- 1duration-3xl (460)ease-outSwaps at once
SF9leave step 1, or go back to itback button columngrid-template-columns0fr → 1frduration-xl (380)ease-outResizes at once
SF10Continue with an async checkcontinue buttonstatus button motion (spinner in, label "Checking")Idle → Checkingduration-xl (380)ease-outSwaps at once
SF11check or submit fails (Error)panel and error linetransform translateX shake0 → -6px, 5px, -3px, 2px, 0duration-xl (380)ease-exitStays still
SF12check or submit fails (Error)error linegrid-template-rows (fold open)0fr → 1frduration-lg (320)ease-outResizes at once
SF13edit a field after Errorerror linegrid-template-rows (fold shut)1fr → 0frduration-lg (320)ease-outResizes at once
SF14Submit on the last stepsubmit buttonstatus button motion (Sending, then check draws, Sent)Idle → Sentduration-xl (380)ease-outSwaps at once
SF15750ms after Sent (Done)header + footergrid-template-rows (fold away)1fr → 0frduration-2xl (420)ease-outResizes at once
SF16750ms after Sent (Done)panelas SF2 to SF4, last step out and done content in, moving forwardlast step → done contentduration-2xl (420)ease-outdelay 60msSwaps 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.