Stepper
Free · MITHeadless, 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
npx astrocraft-ui add navigation/stepperPlain-CSS theme — no build step
npx astrocraft-ui add navigation/stepper --theme cssTailwind theme — needs Tailwind v4
npx astrocraft-ui add navigation/stepper --theme tailwindPlain-CSS theme, re-pointed onto Lumos tokens
npx astrocraft-ui add navigation/stepper --theme css --bridge lumosTailwind theme, re-pointed onto Lumos tokens
npx astrocraft-ui add navigation/stepper --theme tailwind --bridge lumosLive 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.
- Cart
- Delivery
- Payment
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.
| Component | Slots | Variants | Runtime state | Native 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 — 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 — 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 — 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 — 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 — 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>
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.