Theme
On this page (17)
Component Skill — components/steps/component-skill.md

Native basis

<ol> with step indicators for multi-step processes.

Web Platform APIs

<ol>aria-current="step"prefers-reduced-motionprefers-contrastforced-colors

Classes

.steps.step.step-indicator.step-content.step-title.step-description.step-icon

Data attributes

data-variant (on .steps or one .step)primary (default), secondary, accent, info, success, warning, neutral, destructive / error - the color of done and current steps.step-indicator[data-variant="plain"]no circle - just the glyph (an emoji step)data-orientation="vertical" / "responsive"stacked; or stacked below 48rem and in a row abovedata-active-step / data-error-stepstatuses derived by steps.js

§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:

StateTypeValuesDefaultDescription
activeStepnumber—2Index of the current step (1-based); earlier steps render complete. Set by the panel or by clicking a clickable step.
errorStepnumber—0Index rendered with the destructive error status (0 = none) - mirrors the activeStepError intent as an index the CSS can key.
sizeenumxs, sm, md, lg, xl"md"Indicator + type scale (display sizes).
activeStepErrorbooleantrue, falsefalseMarks 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