Skip to content

Components / UI

Stepper

A form, or any flow, in steps: a rail of the steps' names over the step in hand, with Back and Next under it. The steps are the slot's own children, a fieldset with its legend each, so every field stays in one form and sends with it. Without JavaScript nothing is hidden: the steps stand one under the other and the submit button follows the last, as a long form. With it, one step shows at a time; Next checks the step's fields with the browser's own validation before it moves on, Enter in a field does the same, a submit that finds an invalid field in a step behind brings that step back, and whatever follows the stepper in its form (the submit button) shows on the last step only. A step already done is a button on the rail.

Live demo

What is it for?
When suits you?
How do we reach you?

Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.

Measured

JavaScript of its own

2.0 kB raw

979 B gzip.

Uses the shared runtime (1.9 kB raw, once per site). With those included: 3.7 kB raw.

CSS 7.9 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.

Browser support

Baseline · widely available

Chrome 105 · Firefox 121 · Safari 15.4; needs has. Elsewhere: without :has() the Back button also shows on the first step, where it does nothing.

Install

One command adds the integration, the base tokens and every component. Then import what you use.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
import Stepper from '@moonarc/core/Stepper';
---
<form method="post" action="/book/">
  <Stepper steps={['Your visit', 'A time', 'You']}>
    <fieldset><legend>What is it for?</legend><!-- fields --></fieldset>
    <fieldset><legend>When suits you?</legend><!-- fields --></fieldset>
    <fieldset><legend>How do we reach you?</legend><!-- fields --></fieldset>
  </Stepper>
  <button type="submit">Request the appointment</button>
</form>
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs the shared runtime and the base tokens.

terminal
npx shadcn@latest add https://moonarc.dev/r/stepper.json

The CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.

Source

The whole component. Self-contained styles in a cascade layer so your classes always win. If you paste it, also copy runtime.ts.

Stepper.astro
---
/**
 * Stepper — a form, or any flow, in steps: a rail of the steps' names over the step in hand, and Back and Next under
 * it. The steps are the default slot's own children (a <fieldset> with its <legend> each, in a form), so every field
 * stays in one form and sends with it. Without JavaScript nothing is hidden: the steps stand one under the other and the
 * submit button follows the last, as a long form. With it, one step shows at a time; Next checks the step's fields with
 * the browser's own validation before it moves on, Enter in a field does the same, and a submit that finds an invalid
 * field in a step behind brings that step back. Whatever follows the stepper in its form (the submit button) shows on
 * the last step only.
 */
import type { HTMLAttributes } from 'astro/types';

interface Props extends HTMLAttributes<'div'> {
  /** The steps' names, in order: the rail's words. One a child of the slot, two to six. */
  steps: string[];
  /** The buttons' words, and the line a screen reader hears on a new step ({n}, {total} and {name} are filled in). */
  words?: { back?: string; next?: string; step?: string };
  /** The rail's accessible name. */
  label?: string;
}

const { steps, words = {}, label = 'Steps', class: className, ...rest } = Astro.props;
if (steps.length < 2 || steps.length > 6) throw new Error(`Stepper: steps names ${steps.length}; it takes two to six.`);
const w = { back: 'Back', next: 'Next', step: 'Step {n} of {total}: {name}', ...words };
---

<div class:list={['ma-stepper', className]} data-ma-stepper data-say={w.step} style={`--ma-step-count:${steps.length}`} {...rest}>
  <ol class="ma-stepper__rail" aria-label={label}>
    {steps.map((s, i) => (
      <li class="ma-stepper__dot">
        <button class="ma-stepper__to" type="button" data-ma-step-to={i} disabled><span class="ma-stepper__n" aria-hidden="true">{i + 1}</span><span class="ma-stepper__name">{s}</span></button>
      </li>
    ))}
  </ol>
  <div class="ma-stepper__steps"><slot /></div>
  <div class="ma-stepper__nav">
    <button class="ma-stepper__back" type="button" data-ma-step="back">{w.back}</button>
    <button class="ma-stepper__next" type="button" data-ma-step="next">{w.next}</button>
  </div>
  <p class="ma-stepper__say" aria-live="polite"></p>
</div>

<script>
  import { onMount } from '../lib/runtime';

  onMount<HTMLElement>('[data-ma-stepper]', (el, { signal }) => {
    const steps = [...el.querySelector('.ma-stepper__steps')!.children] as HTMLElement[];
    const to = [...el.querySelectorAll<HTMLButtonElement>('[data-ma-step-to]')];
    const say = el.querySelector<HTMLElement>('.ma-stepper__say')!;
    const names = to.map((b) => b.querySelector('.ma-stepper__name')!.textContent ?? '');
    const last = steps.length - 1;
    const form = el.closest('form');
    let at = 0;
    const fields = (s: HTMLElement) => [...s.querySelectorAll<HTMLInputElement>('input, select, textarea')];
    // the first field the browser finds invalid is reported and focused; the step stays
    const valid = (s: HTMLElement) => {
      for (const f of fields(s)) if (!f.checkValidity()) { f.reportValidity(); return false; }
      return true;
    };
    // 'focus' moves the focus into the step and says which it is; 'say' only says it (the browser is about to focus a
    // field itself); 'quiet' does neither (the first paint, a reset)
    const go = (i: number, mode: 'focus' | 'say' | 'quiet') => {
      at = Math.max(0, Math.min(last, i));
      steps.forEach((s, k) => {
        s.toggleAttribute('data-current', k === at);
        s.hidden = k !== at;
      });
      to.forEach((b, k) => {
        b.disabled = k >= at;
        b.parentElement!.toggleAttribute('data-done', k < at);
        if (k === at) b.parentElement!.setAttribute('aria-current', 'step');
        else b.parentElement!.removeAttribute('aria-current');
      });
      el.toggleAttribute('data-last', at === last);
      el.style.setProperty('--ma-step-at', String(at));
      if (mode === 'quiet') return;
      say.textContent = (el.dataset.say ?? '').replace('{n}', String(at + 1)).replace('{total}', String(steps.length)).replace('{name}', names[at] ?? '');
      if (mode === 'say') return;
      const first = fields(steps[at]!).find((f) => f.type !== 'hidden' && !f.disabled) ?? steps[at]!;
      if (first === steps[at]) steps[at]!.tabIndex = -1;
      first.focus({ preventScroll: true });
      if (el.getBoundingClientRect().top < 0) el.scrollIntoView({ block: 'start' });
    };
    const next = () => valid(steps[at]!) && go(at + 1, 'focus');
    el.addEventListener('click', (e) => {
      const b = (e.target as Element).closest<HTMLButtonElement>('button');
      if (!b || !el.contains(b)) return;
      if (b.dataset.maStep === 'next') next();
      else if (b.dataset.maStep === 'back') go(at - 1, 'focus');
      else if (b.dataset.maStepTo) go(Number(b.dataset.maStepTo), 'focus');
    }, { signal });
    // Enter in a field of an earlier step moves on: caught before the browser's implicit submission, which would check
    // every field of the form and point at one in a step the reader has not reached
    el.addEventListener('keydown', (e) => {
      const t = e.target as HTMLInputElement;
      if (e.key !== 'Enter' || at === last || t.tagName !== 'INPUT' || ['button', 'submit', 'reset'].includes(t.type)) return;
      e.preventDefault();
      next();
    }, { signal });
    if (form) {
      // before the form's own listeners: a submit from an earlier step (a script's requestSubmit) moves on instead
      form.addEventListener('submit', (e) => {
        if (at === last) return;
        e.preventDefault();
        e.stopImmediatePropagation();
        next();
      }, { capture: true, signal });
      // a submit that finds an invalid field in a step behind shows that step, so the browser can point at the field
      let jumped = false;
      form.addEventListener('invalid', (e) => {
        const i = steps.findIndex((s) => s.contains(e.target as Node));
        if (jumped || i < 0 || i === at) return;
        jumped = true;
        go(i, 'say');
        setTimeout(() => (jumped = false));
      }, { capture: true, signal });
      form.addEventListener('reset', () => go(0, 'quiet'), { signal });
    }
    el.toggleAttribute('data-on', true);
    go(0, 'quiet');
  });
</script>

<style is:global>
  @layer components {
    :where(.ma-stepper) { --ma-step-at: 0; display: grid; gap: 1.5rem; }
    :where(.ma-stepper__say) { position: absolute; inline-size: 1px; block-size: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
    :where(.ma-stepper__steps) { display: grid; gap: 2rem; }
    :where(.ma-stepper__steps > fieldset) { min-inline-size: 0; margin: 0; padding: 0; border: 0; }
    :where(.ma-stepper__steps > fieldset > legend) { padding: 0; margin-block-end: 1rem; font-weight: 650; }
    /* !important: a step's own display (a fieldset set to grid by the form around it) must not show a hidden step */
    :where(.ma-stepper[data-on] .ma-stepper__steps > [hidden]) { display: none !important; }
    :where(.ma-stepper[data-on] .ma-stepper__steps > [data-current]) { animation: ma-stepper-in var(--ma-duration-base, 250ms) var(--ma-ease-out, ease-out); }
    :where(.ma-stepper[data-on] .ma-stepper__steps > [data-current]:focus) { outline: none; }
    /* the submit button and whatever else follows the stepper in its form: the last step's (important for the same
       reason: a button sets its own display) */
    :where(.ma-stepper[data-on]:not([data-last]) ~ *) { display: none !important; }

    /* the rail: a numbered dot a step on a line that fills to the step in hand */
    :where(.ma-stepper__rail) { position: relative; display: grid; grid-template-columns: repeat(var(--ma-step-count), minmax(0, 1fr)); margin: 0; padding: 0; list-style: none; }
    :where(.ma-stepper__rail)::before,
    :where(.ma-stepper__rail)::after { content: ''; position: absolute; inset-block-start: 0.875rem; inset-inline: calc(50% / var(--ma-step-count)); block-size: 2px; background: var(--border, color-mix(in oklab, currentColor 18%, transparent)); }
    :where(.ma-stepper__rail)::after { background: var(--primary, currentColor); transform-origin: left; scale: calc(var(--ma-step-at) / (var(--ma-step-count) - 1)) 1; transition: scale var(--ma-duration-slow, 400ms) var(--ma-ease-out, ease-out); }
    :where(.ma-stepper__rail:dir(rtl))::after { transform-origin: right; }
    :where(.ma-stepper__dot) { position: relative; z-index: 1; display: grid; justify-items: center; }
    :where(.ma-stepper__to) { display: grid; justify-items: center; gap: 0.4rem; padding: 0; border: 0; background: none; color: inherit; font: inherit; text-align: center; cursor: pointer; }
    :where(.ma-stepper__to:disabled) { cursor: default; }
    :where(.ma-stepper__to:focus-visible .ma-stepper__n) { outline: 2px solid var(--ring, currentColor); outline-offset: 3px; }
    :where(.ma-stepper__n) { display: grid; place-items: center; inline-size: 1.75rem; block-size: 1.75rem; border-radius: 999px; background: var(--background, Canvas); box-shadow: inset 0 0 0 2px var(--border, color-mix(in oklab, currentColor 30%, transparent)); font-size: 0.8125rem; font-weight: 650; font-variant-numeric: tabular-nums; transition: background-color var(--ma-duration-base, 250ms) var(--ma-ease-out, ease-out), color var(--ma-duration-base, 250ms) var(--ma-ease-out, ease-out); }
    :where(.ma-stepper__dot:is([aria-current], [data-done]) .ma-stepper__n) { background: var(--primary, CanvasText); color: var(--primary-foreground, Canvas); box-shadow: none; }
    :where(.ma-stepper__name) { font-size: 0.8125rem; line-height: 1.25; }
    :where(.ma-stepper__dot[aria-current] .ma-stepper__name) { font-weight: 650; }
    :where(.ma-stepper__dot[data-done] .ma-stepper__name) { text-decoration: underline; text-decoration-color: color-mix(in oklab, currentColor 35%, transparent); text-underline-offset: 0.2em; }

    :where(.ma-stepper__nav) { display: flex; flex-wrap: wrap; gap: 0.75rem; }
    :where(.ma-stepper__nav button) { display: inline-flex; align-items: center; min-block-size: 2.75rem; padding: 0 1.25rem; border: 0; border-radius: var(--ma-radius-md, 0.6rem); font: inherit; font-weight: 600; cursor: pointer; }
    :where(.ma-stepper__back) { background: none; color: inherit; box-shadow: inset 0 0 0 1px var(--border, color-mix(in oklab, currentColor 30%, transparent)); }
    :where(.ma-stepper__next) { background: var(--primary, CanvasText); color: var(--primary-foreground, Canvas); }
    :where(.ma-stepper__nav button:focus-visible) { outline: 2px solid var(--ring, currentColor); outline-offset: 3px; }
    :where(.ma-stepper[data-on]:has(.ma-stepper__dot:first-child[aria-current]) .ma-stepper__back) { display: none; }
    :where(.ma-stepper[data-last] .ma-stepper__next) { display: none; }

    /* without JavaScript the rail and the buttons are not there: the steps read as one long form. Last, so it wins over
       the rail's and the buttons' own display at the same (zero) specificity */
    :where(.ma-stepper:not([data-on]) :is(.ma-stepper__rail, .ma-stepper__nav)) { display: none; }

    @keyframes ma-stepper-in { from { opacity: 0; translate: 0 0.5rem; } }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-stepper[data-on] .ma-stepper__steps > [data-current]) { animation: none; }
      :where(.ma-stepper__rail)::after { transition: none; }
      :where(.ma-stepper__n) { transition: none; }
    }
  }
</style>

Props

PropTypeDefaultDescription
stepsstring[]noneThe steps' names in order, one a child of the slot: two to six.
words{ back?, next?, step? }{ back: 'Back', next: 'Next', step: 'Step {n} of {total}: {name}' }The buttons' words and the line a screen reader hears on a new step.
labelstring'Steps'The rail's accessible name.

Reduced motion

A new step appears without its rise, and the rail fills without a transition.

With ClientRouter

Bound per element through the runtime; a fresh page binds again and starts at the first step.

Why it is built this way

Replaces: Stepper (React Bits) · Multi Step Form (Cult UI) · Stepper blocks (shadcnblocks). See the migration table.

a native select · options stagger in

A native select with an animated picker.

Chrome 138 · not Firefox · Safari 27click

one input · paste and autofill work

A one-time code in cells, from one native input.

every browserclick

Progress

Loading

A bar that fills to its value when it scrolls into view, with the number counting up beside it, or a ring with the number in the middle.

newly · Chrome 91 · Firefox 128 · Safari 17scroll
npx astro add moonarc
pnpm astro add moonarc
npx shadcn@latest add …/r/tabs.json

Tabs with an indicator that slides to the selected one and panels that fade in.

every browserclick

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown