Skip to main content
astrocraft-ui/ components · 101

Stepper

Free · MIT

Headless, accessible, and styled entirely through the attribute surface below — bring your own CSS system, or start from the reference theme.

Install

Without a flag you get no styling at all — plain HTML carrying the attribute surface below. --theme copies the same theme in two dialects;--bridge lumos re-points either one onto Lumos for Astro's tokens (it needs a theme to re-point, and Lumos itself, already in your project).

Unstyled — behavior only, you write the CSS

Install command
npx astrocraft-ui add navigation/stepper

Plain-CSS theme — no build step

Install command
npx astrocraft-ui add navigation/stepper --theme css

Tailwind theme — needs Tailwind v4

Install command
npx astrocraft-ui add navigation/stepper --theme tailwind

Plain-CSS theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add navigation/stepper --theme css --bridge lumos

Tailwind theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add navigation/stepper --theme tailwind --bridge lumos

Live demo

Stepper

One number drives it: data-active on the root. The buttons below do nothing but write that attribute — no API, no event name — and the script derives every step's data-state, moves aria-current="step", and swaps the panel.

  1. Cart
  2. Delivery
  3. Payment
Two items, £48.00.
Standard delivery, 3–5 days.
Card ending 4242.

Attribute surface

With no class-merging machinery, these attributes are the whole extension point. State is a data-attribute or a native attribute; visibility is the hiddenattribute. Generated from source by pnpm slots.

ComponentSlotsVariantsRuntime stateNative state
`Step.astro``step`———
`StepIndicator.astro``step-indicator`———
`StepPanel.astro``step-panel`———
`StepSeparator.astro``step-separator`———
`Stepper.astro``step-list` `stepper``data-orientation`: `horizontal` · `vertical``data-active` `data-state`—

Source

What the command copies — 6 files, alias-free so the copy lands standing alone. This is the whole component; there is nothing else.

src/components/ui/navigation/stepper/Step.astro
---
// src/components/ui/navigation/stepper/Step.astro — Stepper compound part (see ../../README.md).
// One step in the flow. Its state is written by the Stepper script from the root's `data-active`, so
// nothing here has to know its own ordinal; `data-state="upcoming"` is the pre-script placeholder and
// is always replaced on load.
//
// Compose StepIndicator (the numbered marker), your label, and StepSeparator (the rule to the next
// step) inside it — the separator goes INSIDE the <li> because an <ol> may contain nothing else.
import type { HTMLAttributes } from "astro/types";

type Props = HTMLAttributes<"li">;

const { class: className, ...rest } = Astro.props;
---

<li class={className} data-slot="step" data-state="upcoming" {...rest}>
  <slot />
</li>
src/components/ui/navigation/stepper/StepIndicator.astro
---
// src/components/ui/navigation/stepper/StepIndicator.astro — Stepper compound part (see ../../README.md).
// The numbered (or ticked) marker. `aria-hidden`, because the position it shows is already in the
// DOM twice over: the steps are an <ol>, and `aria-current="step"` says which one you are on.
// Without it a screen reader reads "2 Payment" — the number twice, once as a list position and once
// as decoration that happens to contain a digit.
//
// A theme swaps the glyph for a tick on `[data-slot="step"][data-state="done"]`.
import type { HTMLAttributes } from "astro/types";

type Props = HTMLAttributes<"span">;

const { class: className, ...rest } = Astro.props;
---

<span aria-hidden="true" class={className} data-slot="step-indicator" {...rest}><slot /></span>
src/components/ui/navigation/stepper/StepPanel.astro
---
// src/components/ui/navigation/stepper/StepPanel.astro — Stepper compound part (see ../../README.md).
// The content for one step. Give it `slot="panels"` so it lands outside the <ol>, and render them in
// the same order as the Steps — the pairing is by position.
//
// Hidden with the `hidden` ATTRIBUTE by the Stepper script, so it needs no `display` of its own and a
// theme must not give it one (structure.css's `[hidden]` rule wins, but a rule that fights it is a
// bug waiting for the day someone drops that file).
import type { HTMLAttributes } from "astro/types";

type Props = HTMLAttributes<"div">;

const { class: className, ...rest } = Astro.props;
---

<div class={className} data-slot="step-panel" {...rest}>
  <slot />
</div>
src/components/ui/navigation/stepper/StepSeparator.astro
---
// src/components/ui/navigation/stepper/StepSeparator.astro — Stepper compound part (see ../../README.md).
// The rule from this step to the next. Decorative, and rendered inside its own Step — omit it on the
// last one, or let a theme hide it with
// `[data-slot="step"]:last-child [data-slot="step-separator"]`.
import type { HTMLAttributes } from "astro/types";

type Props = HTMLAttributes<"span">;

const { class: className, ...rest } = Astro.props;
---

<span aria-hidden="true" class={className} data-slot="step-separator" {...rest}></span>
src/components/ui/navigation/stepper/Stepper.astro
---
// src/components/ui/navigation/stepper/Stepper.astro — headless primitive (see ../../README.md).
// A linear flow — checkout, onboarding, a multi-page form — with one step current, the ones before
// it done, and the ones after it still to come.
//
// ONE number drives all of it: `data-active` on this root, a 1-based ordinal. The script derives
// every Step's `data-state` and `aria-current` from it and shows the matching StepPanel, so advancing
// the flow is a single attribute write from anywhere:
//
//   document.querySelector('[data-slot="stepper"]').dataset.active = "3";
//
// That is the same "driven from outside by the DOM" contract Calendar and Toast use — no exported
// API to import, no event name to remember, and it works from an inline handler, a framework island
// or the console. A MutationObserver on this element is what watches for it.
//
// Steps and panels are paired BY POSITION: the third Step goes with the third StepPanel. Tabs pairs
// by a shared `value` because tabs have no inherent order; a stepper's order is the whole point, and
// a `value` that had to agree with the ordinal would be a second thing to keep in step.
//
// Panels live in the `panels` slot, outside the <ol>, because <ol> may contain nothing but <li>.
//
//   <Stepper active={2}>
//     <Step><StepIndicator>1</StepIndicator>Cart<StepSeparator /></Step>
//     <Step><StepIndicator>2</StepIndicator>Payment</Step>
//     <StepPanel slot="panels">…</StepPanel>
//     <StepPanel slot="panels">…</StepPanel>
//   </Stepper>
//
// Without JavaScript it degrades the way Tabs does: every panel is visible and every step reads
// "upcoming". The markup cannot carry the right answer, because Astro has no context API and a Step
// cannot know its own ordinal at build time.
import "../../../../styles/structure.css";

import type { HTMLAttributes } from "astro/types";

type Props = HTMLAttributes<"div"> & {
  orientation?: "horizontal" | "vertical";
  /** 1-based ordinal of the current step. */
  active?: number;
};

const { orientation = "horizontal", active = 1, class: className, ...rest } = Astro.props;
---

<div
  class={className}
  data-slot="stepper"
  data-orientation={orientation}
  data-active={active}
  {...rest}
>
  <ol data-slot="step-list">
    <slot />
  </ol>
  <slot name="panels" />
</div>

<script>
  import { onReadyOnce } from "../../_once";

  function render(root: HTMLElement) {
    const steps = [...root.querySelectorAll<HTMLElement>('[data-slot="step"]')];
    const panels = [...root.querySelectorAll<HTMLElement>('[data-slot="step-panel"]')];
    const active = Number(root.dataset.active) || 1;

    steps.forEach((step, i) => {
      const n = i + 1;
      step.dataset.state = n < active ? "done" : n === active ? "current" : "upcoming";
      // `aria-current="step"` is the platform's own word for this, so no data attribute is minted
      // for it (contract rule 4). It is REMOVED rather than set to "false" on the others: unlike
      // aria-current on a link, exactly one step is current and the rest are simply not.
      if (n === active) step.setAttribute("aria-current", "step");
      else step.removeAttribute("aria-current");
    });

    // Visibility is the `hidden` attribute, never a class — and structure.css is what makes it beat
    // a theme's `display` on the panel.
    panels.forEach((panel, i) => {
      panel.hidden = i + 1 !== active;
    });
  }

  function wire(root: HTMLElement) {
    render(root);
    // Only `data-active`, and only on the root: `render()` writes `data-state` on the CHILDREN, so
    // a subtree observer would re-enter on its own writes.
    new MutationObserver(() => render(root)).observe(root, {
      attributes: true,
      attributeFilter: ["data-active"],
    });
  }

  onReadyOnce('[data-slot="stepper"]', wire);
</script>
src/components/ui/navigation/stepper/index.ts
import Step from "./Step.astro";
import StepIndicator from "./StepIndicator.astro";
import StepPanel from "./StepPanel.astro";
import Stepper from "./Stepper.astro";
import StepSeparator from "./StepSeparator.astro";

export { Step, StepIndicator, StepPanel, Stepper, StepSeparator };
export default Stepper;

What you get

The component source, copied into your project by npx astrocraft-ui add navigation/stepper — no package dependency, no CSS to fight. Whatever the paid blocks compose, this is it.