Theme
Design your own
On this page (18)
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.

§API

Generated from steps.ts and the shared State API - the descriptions are their JSDoc, the types are checked by the compiler.

States

type StepsState = 'default' - setState(name, config) takes the config of the state it names.

StateDescription
default
The tracker at a step.
Config fieldTypeDescription
activeStep?numberthe current step, 1-based
step?numberthe same as activeStep (when activeStep is not given)
page?numberthe same as activeStep (when neither is given)
errorStep?numberthe step marked as failed, 0 for none
activeStepError?booleantrue marks the current step as failed, false clears that
size?'xs' | 'sm' | 'md' | 'lg' | 'xl'the size

Every element

MemberDescription
el.api.setState<S extends StepsState>(name: S, config?: StepsStateConfigs[S]): unknown
Enter a state: the DOM work runs (also when it is the current state), the store records it.
ArgumentTypeDescription
nameSa declared state (an unknown name throws)
config?StepsStateConfigs[S]that state's config (merged into the stored one when the component merges)

Returns unknown - what the state's DOM work returned - a Promise for an async state (or await settled())

el.api.getState(): { name: StepsState; config: StepsStateConfigs[StepsState]; model?: ElementModel }
The state the element shows now - read back from the DOM, so it includes what the user changed.

Returns { name: StepsState; config: StepsStateConfigs[StepsState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

el.api.render(state?: { name: StepsState; config: StepsStateConfigs[StepsState]; model?: ElementModel }): string
The element's markup in a state - the authored markup with that state applied; a pure function of the state.
ArgumentTypeDescription
state?{ name: StepsState; config: StepsStateConfigs[StepsState]; model?: ElementModel }a state as getState() returns it (default: the current one)

Returns string - the element's outer HTML in that state

el.api.settled(): Promise<void>
Wait for the last state's DOM work (async states: a diagram rendering, a chart mounting).

Returns Promise<void> - resolves when nothing is pending

el.store: Store<{ name: StepsState; config: StepsStateConfigs[StepsState] }>A defuss-store store of the element's state - subscribe to follow every change (also the user's), set it to drive the component.

Registry

MemberDescription
df$.shadcn.stepsApi.setState<S extends StepsState>(el: HTMLElement, name: S, config?: StepsStateConfigs[S]): unknown
Enter a state: the DOM work runs (also when it is the current state), the store records it.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSa declared state (an unknown name throws)
config?StepsStateConfigs[S]that state's config (merged into the stored one when the component merges)

Returns unknown - what the state's DOM work returned - a Promise for an async state (await it, or el.api.settled())

df$.shadcn.stepsApi.getState(el: HTMLElement): { name: StepsState; config: StepsStateConfigs[StepsState]; model?: ElementModel }
The state the element shows now - read back from the DOM, so it includes what the user changed.
ArgumentTypeDescription
elHTMLElementthe component's element

Returns { name: StepsState; config: StepsStateConfigs[StepsState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

df$.shadcn.stepsApi.render(state: { name: StepsState; config: StepsStateConfigs[StepsState]; model?: ElementModel }): string
The element's markup in a state - the authored markup with that state applied; a pure function of the state.
ArgumentTypeDescription
state{ name: StepsState; config: StepsStateConfigs[StepsState]; model?: ElementModel }a state as getState() returns it (with its model)

Returns string - the element's outer HTML in that state

df$.shadcn.stepsApi.store(el: HTMLElement): Store<{ name: StepsState; config: StepsStateConfigs[StepsState] }>
The element's store (bindComponent made it).
ArgumentTypeDescription
elHTMLElementthe component's element

Returns Store<{ name: StepsState; config: StepsStateConfigs[StepsState] }> - a defuss-store store of { name, config } - subscribe to follow every change, set it to drive the component

df$.shadcn.stepsApi.commit<S extends StepsState>(el: HTMLElement, name: S, config?: StepsStateConfigs[S]): void
Record a state the element reached on its own (no DOM work) - for a component's own handlers.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSthe state it is in
config?StepsStateConfigs[S]its config
df$.shadcn.stepsStates: StepsState[]The declared states, 'default' first: default.

§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, defussQuery, componentState, bindComponent } from '../../../shared/state-api.js';
const df$ = defussGlobals();
const dfDollar = defussQuery();
const stepsStates = ['default'];
// VERIFIED: (verify's API docs gate) the states below are exactly the declared ones, each
// described, and every config field typed, described and named in the code.
/** setState() configs per state (getState() reports the live progress). */
export interface StepsStateConfigs {
  /** The tracker at a step. */
  default: {
    /** the current step, 1-based */
    activeStep?: number;
    /** the same as activeStep (when activeStep is not given) */
    step?: number;
    /** the same as activeStep (when neither is given) */
    page?: number;
    /** the step marked as failed, 0 for none */
    errorStep?: number;
    /** true marks the current step as failed, false clears that */
    activeStepError?: boolean;
    /** the size */
    size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
  };
}
/** 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(dfDollar(ol).find('.step').toArray() as HTMLElement[]);
  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.
 */
/**
 * The markup of a state, for render(): the tracker attributes the state's
 * config names - written only where they differ from what the markup already
 * says (getState() reports the derived values: activeStep 1, size md, … even
 * when none was authored) - then the same renderSteps() the live list runs on
 * setState. Applied to a detached copy of the authored markup.
 */
function applyMarkup(ol: HTMLElement, config: Record<string, unknown> = {}): void {
  if (config.activeStep !== undefined && numAttr(ol, 'activeStep', 1) !== Number(config.activeStep))
    ol.dataset.activeStep = String(config.activeStep);
  if (config.errorStep !== undefined && numAttr(ol, 'errorStep', 0) !== Number(config.errorStep)) {
    if (Number(config.errorStep)) ol.dataset.errorStep = String(config.errorStep);
    else delete ol.dataset.errorStep;
  }
  if (config.size !== undefined && (ol.dataset.size ?? 'md') !== config.size) ol.dataset.size = String(config.size);
  if (ol.hasAttribute('data-active-step')) renderSteps(ol);
}
function triggerStateChange(ol: HTMLElement, stateName: string, config: Record<string, unknown> = {}): void {
  if (stateName !== 'default') return;
  // only what differs is written: setState(getState()) changes nothing (getState
  // reports the derived activeStep 1 / errorStep 0 even when none was authored)
  const a = config.activeStep ?? config.step ?? config.page;
  if (a !== undefined && numAttr(ol, 'activeStep', 1) !== Number(a)) ol.dataset.activeStep = String(a);
  if (config.errorStep !== undefined) {
    if (numAttr(ol, 'errorStep', 0) !== Number(config.errorStep)) {
      if (Number(config.errorStep)) ol.dataset.errorStep = String(config.errorStep);
      else delete ol.dataset.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 ?? 'md') !== config.size) ol.dataset.size = String(config.size);
  // the same opt-in as init: only a data-driven list is re-mapped - a static
  // list keeps its authored statuses (setState('default') must not erase them)
  if (ol.hasAttribute('data-active-step')) renderSteps(ol);
}
/** Registry-level API; pass the <ol> explicitly. Unknown names throw. */
export const stepsApi = componentState({
  component: 'steps',
  states: stepsStates,
  apply: (ol, state) => triggerStateChange(ol, state.name, state.config),
  read: (ol, state) => {
    // reflect reality: clicking a clickable step moves the tracker without setState()
    return {
      name: ol.dataset.stateName || 'default',
      config: {
        ...state.config,
        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',
      },
    };
  },
  markup: (el, state) => applyMarkup(el as HTMLElement, state.config),
});
df$.stepsApi = stepsApi;
df$.stepsStates = stepsStates;
function init(): void {
  (dfDollar('.steps:not([data-init])').toArray() as HTMLElement[]).forEach((ol) => {
    ol.dataset.init = '';
    // el.store + el.api (AGENTS.md "State through stores")
    bindComponent(ol, stepsApi);
    // 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(dfDollar(ol).find('.step').toArray());
      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