StepsATM
Multi-step progress indicator with complete, current, and upcoming states.
On this page (17)
§Default
§With Form Content
Steps with descriptions, composed with a .card form panel and .btn navigation.
§With checkmarks
Complete steps show a checkmark SVG instead of the step number.
§Vertical
Add data-orientation='vertical' for a stacked layout with a vertical connector line.
§Error State
data-error-step on the list marks a step as failed - here the active step 2 - and the script renders it with data-status='error'. Keep data-status='error' and aria-invalid='true' on the item too, so the error also shows without JavaScript.
§Colors
data-variant on the list colors its done and current steps: primary (the default), secondary, accent, info, success, warning, neutral, destructive (alias error). Secondary and accent take the theme's chart colors; success, warning and info are fixed colors.
§A color per step
data-variant on a single .step overrides the list's color - here three info steps and a failed last step (data-error-step) showing a question mark.
§Symbols
The indicator holds whatever you put in it - a number, a symbol, nothing. Here ?, !, ✓, ✕, ★, an empty one and a dot, all on neutral.
§Emoji and icons
Wrap an emoji or an icon in a .step-icon for a glyph a size up from the numbers. data-variant="plain" on the indicator drops the circle - just the emoji (a symbol or icon there takes the step's color).
§Responsive
data-orientation="responsive": stacked vertically below 48rem, in a row from there up. Try the device sizes in the toolbar.
§Scrollable
Many steps in a narrow space: an overflow-x-auto wrapper and a minimum width on the list - the row scrolls instead of squeezing. Mixed per-step colors.
§Sizes
The full five-step scale via data-size on .steps - the default equals md.
§Clickable Steps
Add data-clickable to individual .step elements to show a pointer cursor and hover affordance on the indicator. Wire up navigation with your own JavaScript.
§Density
Set data-density on the component root to scale its internal whitespace. A whitespace policy, not a zoom: only gaps and padding scale (ratio 0.75 / 1 / 1.25), typography and fixed dimensions stay identical. comfortable matches the unsized default.
§States
The runtime declares the single default state; the editable contract below lives on the nav/list as data-* attributes, driven per instance through the bound api or the State tab:
Machine contract - verified against steps.schema.json by bun run verify:
| State | Type | Values | Default | Description |
|---|---|---|---|---|
activeStep | number | — | 2 | Index of the current step (1-based); earlier steps render complete. Set by the panel or by clicking a clickable step. |
errorStep | number | — | 0 | Index rendered with the destructive error status (0 = none) - mirrors the activeStepError intent as an index the CSS can key. |
size | enum | xs, sm, md, lg, xl | "md" | Indicator + type scale (display sizes). |
activeStepError | boolean | true, false | false | Marks the CURRENT step with the destructive error status (true = the active step failed, false clears). Equivalent to setting errorStep to the active index. |
§JavaScript view file
The optional progressive-enhancement layer: a data-active-step="N" list has its item statuses mapped (< N complete, = N current, optional data-error-step destructive - the literal true marks the active step), aria-current="step" follows, and data-clickable steps become real controls (click / Enter / Space). Authored data-status markup without data-active-step renders exactly as written.
// -- Steps ----------------------------------------------------// Turns the static step markup into a live progress tracker. The <ol class=// "steps"> carries its contract as data attributes (data-active-step /// data-error-step); this script maps them onto the items - steps before the// active one render `data-status="complete"`, the active one `current`, an// optional error step `error` - and makes data-clickable steps interactive// (AGENTS.md "State API").//// Attribute-driven (same reason as pagination.ts): the sandbox bridge writes// data attributes directly, and the MutationObserver turns an attribute write// into a re-mapping, so the panel editor drives the component without any// component-specific JS on the caller's side. Without this file the authored// markup still renders correctly (progressive enhancement).// Shared preamble (AGENTS.md "State API"); the implementation lives in core.js —// build.ts rewrites this import into a df$.shadcn.shared binding in dist/.import { defussGlobals } from '../../shared/state-api.js';const df$ = defussGlobals();const stepsStates = ['default'];/** Read one numeric data attribute (camelCase key) with a fallback. */const numAttr = (el: HTMLElement, key: string, fallback: number): number => { const v = parseInt(el.dataset[key] ?? '', 10); return Number.isFinite(v) ? v : fallback;};/** * Map the attribute contract onto the items: statuses + aria-current. Pure * function of the attributes - idempotent, safe to run after every change. */function renderSteps(ol: HTMLElement): void { const items = Array.from(ol.querySelectorAll<HTMLElement>('.step')); if (items.length === 0) return; const total = items.length; const active = Math.min(total, Math.max(1, numAttr(ol, 'activeStep', 1))); // two spellings of the error marker: a numeric index, or the literal "true" // (what a boolean checkbox mutation writes) meaning "the ACTIVE step failed" const raw = ol.dataset.errorStep ?? ''; const error = raw === 'true' ? active : parseInt(raw, 10) || 0; // guarded write - same-value setAttribute fires a MO record (re-render loop) if (ol.dataset.activeStep !== String(active)) ol.dataset.activeStep = String(active); items.forEach((item, i) => { const n = i + 1; // an explicit error step overrides the positional status (one error only) const status = n === error ? 'error' : n < active ? 'complete' : n === active ? 'current' : null; if (status) item.dataset.status = status; else delete item.dataset.status; if (status === 'current') item.setAttribute('aria-current', 'step'); else item.removeAttribute('aria-current'); });}/** * UI side of setState: 'default' applies an optional { activeStep|step|page, * errorStep|activeStepError, min?, size } config onto the attributes and * re-maps. activeStepError (boolean, per the panel) maps to the ERROR INDEX: * true marks the active step, false clears it. */function triggerStateChange(ol: HTMLElement, stateName: string, config: Record<string, unknown> = {}): void { if (stateName !== 'default') return; const a = config.activeStep ?? config.step ?? config.page; if (a !== undefined) ol.dataset.activeStep = String(a); if (config.errorStep !== undefined) ol.dataset.errorStep = String(config.errorStep); else if (config.activeStepError === true) ol.dataset.errorStep = ol.dataset.activeStep ?? '1'; else if (config.activeStepError === false) delete ol.dataset.errorStep; if (config.size !== undefined) ol.dataset.size = String(config.size); renderSteps(ol);}/** Registry-level API; pass the <ol> explicitly. Unknown names throw. */export const stepsApi = { setState(ol: HTMLElement, stateName: string, config: Record<string, unknown> = {}) { if (!stepsStates.includes(stateName)) { throw new Error(`steps: unknown state "${stateName}" (supported: ${stepsStates.join(', ')})`); } triggerStateChange(ol, stateName, config); // state lives on the ELEMENT, not module scope (AGENTS.md "State API") ol.dataset.stateName = stateName; ol._stateConfig = config; }, getState(ol: HTMLElement) { // reflect reality: clicking a clickable step moves the tracker without setState() return { name: ol.dataset.stateName || 'default', config: { ...ol._stateConfig, activeStep: numAttr(ol, 'activeStep', 1), activeStepError: numAttr(ol, 'errorStep', 0) === numAttr(ol, 'activeStep', 1) && numAttr(ol, 'errorStep', 0) !== 0, errorStep: numAttr(ol, 'errorStep', 0), size: ol.dataset.size ?? 'md', }, }; },};df$.stepsApi = stepsApi;df$.stepsStates = stepsStates;function init(): void { document.querySelectorAll<HTMLElement>('.steps:not([data-init])').forEach((ol) => { ol.dataset.init = ''; // bind-scope the api per instance: `$('#checkout').api.setState('default', { activeStep: 3 })` ol.api = { setState: (stateName: string, config?: Record<string, unknown>) => stepsApi.setState(ol, stateName, config), getState: () => stepsApi.getState(ol), }; // opt-in: only a data-driven <ol> (declaring data-active-step) is mapped — // authored data-status markup stays exactly as written (progressive // enhancement; the e2e fixture's static statuses stay stable) if (ol.hasAttribute('data-active-step')) renderSteps(ol); // attribute-driven re-map: panel edits (bridge writes data attrs) and // setState both land here - the attributes ARE the state new MutationObserver(() => { if (ol.hasAttribute('data-active-step')) renderSteps(ol); }).observe(ol, { attributes: true, attributeFilter: ['data-active-step', 'data-error-step', 'data-size'], }); // data-clickable steps become a real control: click (and Enter/Space on the // focusable indicator) moves the tracker ol.addEventListener('click', (e) => { const item = (e.target as HTMLElement).closest<HTMLElement>('.step[data-clickable]'); if (!item || !ol.contains(item)) return; const items = Array.from(ol.querySelectorAll('.step')); ol.dataset.activeStep = String(items.indexOf(item) + 1); delete ol.dataset.errorStep; // navigating clears the error }); ol.addEventListener('keydown', (e) => { if (e.key !== 'Enter' && e.key !== ' ') return; const ind = (e.target as HTMLElement).closest<HTMLElement>('.step[data-clickable] .step-indicator'); if (!ind) return; e.preventDefault(); ind.click(); }); });}init();new MutationObserver(init).observe(document, { childList: true, subtree: true });§CSS view file
Styles for the steps component. Uses design tokens for colors, spacing, and radius.
@layer components { /* --_accent / --_accent-fg: the color of done (complete) and current steps - --primary unless data-variant (on the list, or on one step) picks another */ .steps { --_accent: var(--primary); --_accent-fg: var(--primary-foreground); display: flex; gap: 0.5rem; list-style: none; margin: 0; padding: 0; } .step { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 0.5rem; position: relative; text-align: center; &:not(:last-child)::after { content: ''; position: absolute; top: 1rem; left: calc(50% + 1.25rem); right: calc(-50% + 1.25rem); height: 2px; background-color: var(--border); } &[data-status='complete']::after { background-color: var(--_accent); } &[data-status='error']::after { background-color: var(--destructive); } } .step-indicator { display: flex; align-items: center; justify-content: center; width: 2rem; height: 2rem; border-radius: 9999px; font-size: 0.75rem; font-weight: 500; border: 2px solid var(--border); color: var(--muted-foreground); background-color: var(--background); position: relative; z-index: 1; .step[data-status='current'] & { border-color: var(--_accent); color: var(--_accent); } .step[data-status='complete'] & { border-color: var(--_accent); background-color: var(--_accent); color: var(--_accent-fg); } .step[data-status='error'] & { border-color: var(--destructive); background-color: var(--destructive); color: var(--destructive-foreground); } & svg { width: 0.875rem; height: 0.875rem; pointer-events: none; } /* a glyph of your own - emoji, symbol, icon - a size up from the number */ & .step-icon { font-size: 1.5em; line-height: 1; display: grid; place-items: center; } & .step-icon svg { width: 1.2em; height: 1.2em; } } /* data-variant="plain" on the indicator: no circle - just the glyph (an emoji step); the status still tints a symbol or icon */ .steps .step .step-indicator[data-variant='plain'] { border-color: transparent; background-color: transparent; font-size: 1.25rem; } .steps .step:is([data-status='complete'], [data-status='current']) .step-indicator[data-variant='plain'] { color: var(--_accent); } .steps .step[data-status='error'] .step-indicator[data-variant='plain'] { color: var(--destructive); } /* -- Colors: data-variant on .steps (every step) or on one .step ------- secondary / accent use --chart-2 / --chart-4 (the tokens' --secondary / --accent are pale surfaces); success / warning / info are literals - the token set has no such pairs. */ :is(.steps, .step)[data-variant='neutral'] { --_accent: var(--foreground); --_accent-fg: var(--background); } :is(.steps, .step)[data-variant='primary'] { --_accent: var(--primary); --_accent-fg: var(--primary-foreground); } :is(.steps, .step)[data-variant='secondary'] { --_accent: var(--chart-2); --_accent-fg: #fff; } :is(.steps, .step)[data-variant='accent'] { --_accent: var(--chart-4); --_accent-fg: #fff; } :is(.steps, .step)[data-variant='info'] { --_accent: #0ea5e9; --_accent-fg: #fff; } :is(.steps, .step)[data-variant='success'] { --_accent: #16a34a; --_accent-fg: #fff; } :is(.steps, .step)[data-variant='warning'] { --_accent: #f59e0b; --_accent-fg: #1c1917; } :is(.steps, .step):is([data-variant='destructive'], [data-variant='error']) { --_accent: var(--destructive); --_accent-fg: var(--destructive-foreground); } .step-content { display: flex; flex-direction: column; gap: 0.125rem; } .step-title { margin: 0; font-size: 0.8125rem; font-weight: 500; color: var(--foreground); } .step-description { margin: 0; font-size: 0.75rem; color: var(--muted-foreground); } /* -- Clickable steps ---------------------------------------- */ .step[data-clickable] .step-indicator { cursor: pointer; &:hover { opacity: 0.85; } } /* -- Size variants ------------------------------------------ Five-step ladder (xs–xl). md == the unsized default (2rem indicator), so md is selectable everywhere without a visual jump. Connector offsets follow the existing pattern per indicator size I: horizontal top = I/2, left inset = I/2 + 0.25rem; vertical top = I + 0.25rem, left = I/2 - 1px (centers the 2px bar under the indicator). */ .steps[data-size='xs'] { & .step:not(:last-child)::after { top: 0.625rem; left: calc(50% + 0.875rem); right: calc(-50% + 0.875rem); } & .step-indicator { width: 1.25rem; height: 1.25rem; font-size: 0.625rem; } & .step-indicator svg { width: 0.625rem; height: 0.625rem; } & .step-title { font-size: 0.6875rem; } & .step-description { font-size: 0.625rem; } &[data-orientation='vertical'] .step:not(:last-child)::after { top: 1.5rem; left: 0.5625rem; height: calc(100% - 1.5rem); } } .steps[data-size='sm'] { & .step:not(:last-child)::after { top: 0.75rem; left: calc(50% + 1rem); right: calc(-50% + 1rem); } & .step-indicator { width: 1.5rem; height: 1.5rem; font-size: 0.6875rem; } & .step-indicator svg { width: 0.75rem; height: 0.75rem; } & .step-title { font-size: 0.75rem; } & .step-description { font-size: 0.6875rem; } &[data-orientation='vertical'] .step:not(:last-child)::after { top: 1.75rem; left: 0.6875rem; height: calc(100% - 1.75rem); } } .steps[data-size='md'] { & .step:not(:last-child)::after { top: 1rem; left: calc(50% + 1.25rem); right: calc(-50% + 1.25rem); } & .step-indicator { width: 2rem; height: 2rem; font-size: 0.75rem; } & .step-indicator svg { width: 0.875rem; height: 0.875rem; } & .step-title { font-size: 0.8125rem; } & .step-description { font-size: 0.75rem; } &[data-orientation='vertical'] .step:not(:last-child)::after { top: 2.25rem; left: 0.9375rem; height: calc(100% - 2.25rem); } } .steps[data-size='lg'] { & .step:not(:last-child)::after { top: 1.25rem; left: calc(50% + 1.5rem); right: calc(-50% + 1.5rem); } & .step-indicator { width: 2.5rem; height: 2.5rem; font-size: 0.875rem; } & .step-indicator svg { width: 1rem; height: 1rem; } & .step-title { font-size: 0.9375rem; } & .step-description { font-size: 0.8125rem; } &[data-orientation='vertical'] .step:not(:last-child)::after { top: 2.75rem; left: 1.1875rem; height: calc(100% - 2.75rem); } } .steps[data-size='xl'] { & .step:not(:last-child)::after { top: 1.5rem; left: calc(50% + 1.75rem); right: calc(-50% + 1.75rem); } & .step-indicator { width: 3rem; height: 3rem; font-size: 1rem; } & .step-indicator svg { width: 1.125rem; height: 1.125rem; } & .step-title { font-size: 1.0625rem; } & .step-description { font-size: 0.875rem; } &[data-orientation='vertical'] .step:not(:last-child)::after { top: 3.25rem; left: 1.4375rem; height: calc(100% - 3.25rem); } } /* -- Vertical orientation ---------------------------------- */ .steps[data-orientation="vertical"] { flex-direction: column; gap: 0; & .step { flex-direction: row; align-items: flex-start; text-align: left; padding-bottom: 1.5rem; &:not(:last-child)::after { top: 2.25rem; left: 0.9375rem; right: auto; width: 2px; height: calc(100% - 2.25rem); } } & .step-content { padding-top: 0.25rem; } } /* -- Responsive: data-orientation="responsive" stacks vertically below 48rem and runs horizontally from there up (DaisyUI's steps-vertical lg:steps-horizontal) */ @media (max-width: 47.99rem) { .steps[data-orientation="responsive"] { flex-direction: column; gap: 0; & .step { flex-direction: row; align-items: flex-start; text-align: left; padding-bottom: 1.5rem; &:not(:last-child)::after { top: 2.25rem; left: 0.9375rem; right: auto; width: 2px; height: calc(100% - 2.25rem); } } & .step-content { padding-top: 0.25rem; } &[data-size='xs'] .step:not(:last-child)::after { top: 1.5rem; left: 0.5625rem; height: calc(100% - 1.5rem); } &[data-size='sm'] .step:not(:last-child)::after { top: 1.75rem; left: 0.6875rem; height: calc(100% - 1.75rem); } &[data-size='md'] .step:not(:last-child)::after { top: 2.25rem; left: 0.9375rem; height: calc(100% - 2.25rem); } &[data-size='lg'] .step:not(:last-child)::after { top: 2.75rem; left: 1.1875rem; height: calc(100% - 2.75rem); } &[data-size='xl'] .step:not(:last-child)::after { top: 3.25rem; left: 1.4375rem; height: calc(100% - 3.25rem); } } } /* -- Accessibility ------------------------------------------ */ @media (prefers-reduced-motion: reduce) { .step-indicator, .step:not(:last-child)::after { transition: none; } } @media (prefers-contrast: more) { .step-indicator { border-width: 3px; } .step:not(:last-child)::after { height: 3px; } .steps[data-orientation="vertical"] .step:not(:last-child)::after { width: 3px; height: calc(100% - 2.25rem); } } @media (forced-colors: active) { .step-indicator { border-color: ButtonText; .step[data-status='current'] & { border-color: Highlight; color: Highlight; } .step[data-status='complete'] & { border-color: Highlight; background: Highlight; color: HighlightText; } .step[data-status='error'] & { border-color: Mark; background: Mark; color: MarkText; } } .step:not(:last-child)::after { background: ButtonText; } .step[data-status='complete']:not(:last-child)::after { background: Highlight; } .step[data-status='error']:not(:last-child)::after { background: Mark; } } /* -- Density ---------------------------------------------------- data-density on the .steps root scales the flex gaps only - the size ladder owns indicator/typography, size and density stay independent. comfortable == the unsized default (.5rem / .125rem). */ .steps:where([data-density="compact"]) { gap: 0.375rem; & .step { gap: 0.375rem; } & .step-content { gap: 0.0625rem; } } .steps:where([data-density="comfortable"]) { gap: 0.5rem; & .step { gap: 0.5rem; } & .step-content { gap: 0.125rem; } } .steps:where([data-density="spacious"]) { gap: 0.75rem; & .step { gap: 0.75rem; } & .step-content { gap: 0.25rem; } }}Comments, ideas or improvements? Edit this page's source on GitHub