All components solidsubtlexssmmdlg
Steps
Multi-step progress with numbered indicators, completion checks, and connecting separators.
1234567891011121314151617181920212223import { Steps } from "#components/ui/base-ui/steps.tsx";
const steps = ["Account", "Details", "Review"];
export function Example() { return ( // Base UI has no steps machine; the component carries the controlled // step itself — step/defaultStep/onStepChange, parts via useRender <Steps.Root count={steps.length} defaultStep={1} size="md"> <Steps.List> {steps.map((title, index) => ( <Steps.Item key={title} index={index}> <Steps.Trigger> <Steps.Indicator>{index + 1}</Steps.Indicator> <Steps.Title>{title}</Steps.Title> </Steps.Trigger> <Steps.Separator /> </Steps.Item> ))} </Steps.List> </Steps.Root> );}Variants
Sizes
Install
1npx stylexblocks add stepsSource
components/ui/base-ui/steps.tsx
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556import * as stylex from "@stylexjs/stylex";import { createContext, useContext, useMemo, useState } from "react";import { mergeProps } from "@base-ui-components/react/merge-props";import { useRender } from "@base-ui-components/react/use-render";import { createSlotRecipeContext } from "#lib/base-ui/create-slot-recipe-context.tsx";import { defineSlotRecipe, resolveSlotStyles, variantDataAttrs, type SlotRecipeVariants, type WithStyleOverrides,} from "#lib/recipe-props.ts";import { stepsIndicatorIconVars } from '@stylexblocks/ui/slot-recipes/steps.stylex.ts'
import { colorPalette } from '@stylexblocks/ui/color-palette.stylex.ts'import { fontSizes } from '@stylexblocks/ui/tokens/font-sizes.stylex.ts'import { fontWeights } from '@stylexblocks/ui/tokens/font-weights.stylex.ts'import { lineHeights } from '@stylexblocks/ui/tokens/line-heights.stylex.ts'import { radii } from '@stylexblocks/ui/tokens/radii.stylex.ts'import { semanticColors } from '@stylexblocks/ui/semantic-tokens/colors.stylex.ts'import { semanticRadii } from '@stylexblocks/ui/semantic-tokens/radii.stylex.ts'import { sizes } from '@stylexblocks/ui/tokens/sizes.stylex.ts'import { spacing } from '@stylexblocks/ui/tokens/spacing.stylex.ts'
const stepsRoot = stylex.create({ base: { display: 'flex', width: sizes.full, },})
const stepsRootSizes = stylex.create({ xs: { gap: spacing._2_5, }, sm: { gap: spacing._3, }, md: { gap: spacing._4, }, lg: { gap: spacing._6, },})
const stepsRootOrientations = stylex.create({ vertical: { flexDirection: 'row', height: '100%', }, horizontal: { flexDirection: 'column', width: '100%', },})
const stepsList = stylex.create({ base: { display: 'flex', justifyContent: 'space-between', '--steps-gutter': spacing._3, '--steps-thickness': '2px', },})
const stepsListSizes = stylex.create({ xs: { '--steps-size': sizes._6, '--steps-icon-size': sizes._3_5, fontSize: fontSizes.xs, lineHeight: lineHeights.xs, }, sm: { '--steps-size': sizes._8, '--steps-icon-size': sizes._4, fontSize: fontSizes.xs, lineHeight: lineHeights.xs, }, md: { '--steps-size': sizes._10, '--steps-icon-size': sizes._4, fontSize: fontSizes.sm, lineHeight: lineHeights.sm, }, lg: { '--steps-size': sizes._12, '--steps-icon-size': sizes._5, fontSize: fontSizes.md, lineHeight: lineHeights.md, },})
const stepsListOrientations = stylex.create({ vertical: { flexDirection: 'column', alignItems: 'flex-start', }, horizontal: { flexDirection: 'row', alignItems: 'center', },})
const stepsTitle = stylex.create({ base: { fontWeight: fontWeights.medium, color: semanticColors.fg, },})
const stepsTitleSizes = stylex.create({ xs: { fontSize: fontSizes.sm, lineHeight: lineHeights.sm, }, sm: { fontSize: fontSizes.sm, lineHeight: lineHeights.sm, }, md: { fontSize: fontSizes.sm, lineHeight: lineHeights.sm, }, lg: { fontSize: fontSizes.md, lineHeight: lineHeights.md, },})
const stepsDescription = stylex.create({ base: { color: semanticColors.fgMuted, },})
const stepsSeparator = stylex.create({ base: { backgroundColor: semanticColors.border, flex: 1, },})
const stepsSeparatorVariants = stylex.create({ solid: {}, subtle: {},})
const stepsSeparatorOrientations = stylex.create({ vertical: { position: 'absolute', width: 'var(--steps-thickness)', height: '100%', maxHeight: 'calc(100% - var(--steps-size) - var(--steps-gutter) * 2)', top: 'calc(var(--steps-size) + var(--steps-gutter))', insetInlineStart: 'calc(var(--steps-size) / 2 - 1px)', }, horizontal: { width: '100%', height: 'var(--steps-thickness)', marginInline: 'var(--steps-gutter)', },})
const stepsIndicator = stylex.create({ base: { display: 'flex', justifyContent: 'center', alignItems: 'center', flexShrink: 0, borderRadius: radii.full, fontWeight: fontWeights.medium, width: 'var(--steps-size)', height: 'var(--steps-size)', [stepsIndicatorIconVars.flexShrink]: 0, [stepsIndicatorIconVars.width]: 'var(--steps-icon-size)', [stepsIndicatorIconVars.height]: 'var(--steps-icon-size)', },})
const stepsIndicatorVariants = stylex.create({ solid: {}, subtle: {},})
const stepsIndicatorIcon = stylex.create({ base: { flexShrink: stepsIndicatorIconVars.flexShrink, width: stepsIndicatorIconVars.width, height: stepsIndicatorIconVars.height, },})
const stepsItem = stylex.create({ base: { position: 'relative', display: 'flex', flex: '1 0 0', },})
const stepsItemOrientations = stylex.create({ vertical: { alignItems: 'flex-start', }, horizontal: { alignItems: 'center', },})
const stepsTrigger = stylex.create({ base: { display: 'flex', alignItems: 'center', gap: spacing._3, textAlign: 'start', outlineWidth: { default: null, ':focus-visible': '1px', }, outlineOffset: { default: null, ':focus-visible': '2px', }, outlineStyle: { default: null, ':focus-visible': 'solid', }, outlineColor: { default: null, ':focus-visible': colorPalette.focusRing, }, borderRadius: semanticRadii.control, },})
const stepsContent = stylex.create({ base: { outlineWidth: { default: null, ':focus-visible': '1px', }, outlineOffset: { default: null, ':focus-visible': '2px', }, outlineStyle: { default: null, ':focus-visible': 'solid', }, outlineColor: { default: null, ':focus-visible': colorPalette.focusRing, }, },})
export type StepsOrientation = keyof typeof stepsRootOrientations
export type StepsVariant = keyof typeof stepsSeparatorVariants
export type StepsSize = keyof typeof stepsRootSizes
// What the generated recipe doesn't carry: list/trigger browser resets and// the per-state indicator/separator treatment (palette-aware). StyleX keys// those off `data-complete` / `data-current` / `data-last` that the item// wrappers copy from the component's step context.const chrome = stylex.create({ list: { listStyle: "none", margin: 0, padding: 0 }, trigger: { borderWidth: 0, backgroundColor: "transparent", padding: 0, cursor: "pointer", }, indicator: { borderWidth: { default: "2px", ":is([data-complete])": 0, }, borderStyle: "solid", borderColor: { default: semanticColors.border, ":is([data-current])": colorPalette.solid, }, color: { default: semanticColors.fgMuted, ":is([data-current])": colorPalette.fg, ":is([data-complete])": colorPalette.contrast, }, backgroundColor: { default: null, ":is([data-complete])": colorPalette.solid, }, }, separatorComplete: { backgroundColor: { default: null, ":is([data-complete])": colorPalette.solid, }, }, separatorLast: { display: { default: null, ":is([data-last])": "none", }, },});
// defineSlotRecipe validates the shape at compile time: default values must// exist in the axis maps, and slot keys must be `styles` or a declared axis.// Axis maps are looked up by pluralized axis name (`orientation` →// `orientations`). Nested `icon:` keys are skipped — Icon composes// `stepsIndicatorIcon` at the composition layer.const stepsSlotRecipe = defineSlotRecipe({ slots: { root: { styles: stepsRoot, sizes: stepsRootSizes, orientations: stepsRootOrientations, }, list: { styles: stepsList, sizes: stepsListSizes, orientations: stepsListOrientations, }, title: { styles: stepsTitle, sizes: stepsTitleSizes }, description: { styles: stepsDescription }, separator: { styles: stepsSeparator, variants: stepsSeparatorVariants, orientations: stepsSeparatorOrientations, }, indicator: { styles: stepsIndicator, variants: stepsIndicatorVariants }, item: { styles: stepsItem, orientations: stepsItemOrientations }, trigger: { styles: stepsTrigger }, content: { styles: stepsContent }, }, defaultVariants: { size: "md", variant: "solid", orientation: "horizontal" },});
const { withProvider, withContext, VariantContext } = createSlotRecipeContext(stepsSlotRecipe);
type StepsVariants = SlotRecipeVariants<typeof stepsSlotRecipe>;
// Base UI has no steps machine; the component carries the controlled step// state itself and hands it to the parts through context.interface StepsState { step: number; count: number; setStep: (step: number) => void;}
const StepsContext = createContext<StepsState>({ step: 0, count: 0, setStep: () => {},});
const ItemContext = createContext(0);
/** * Steps — the generated recipe composed through Base UI's `useRender` / * `mergeProps` primitives with a controlled current step. `<Steps.Root * variant size orientation count>` resolves the recipe once and every part * inherits it through context; `step` / `defaultStep` / `onStepChange` * control progression. Composition per part via `render`. */export type StepsRootProps = WithStyleOverrides<useRender.ComponentProps<"div">> & { variant?: StepsVariants["variant"]; size?: StepsVariants["size"]; orientation?: StepsVariants["orientation"]; /** Total number of steps (drives last-separator handling). */ count: number; /** Controlled current step index. */ step?: number; /** Initial step index when uncontrolled. */ defaultStep?: number; onStepChange?: (details: { step: number }) => void;};
function RootNode({ render, count, step, defaultStep, onStepChange, ...rest}: Omit<StepsRootProps, "variant" | "size" | "orientation" | "palette" | "style">) { const v = useContext(VariantContext); const [innerStep, setInnerStep] = useState(defaultStep ?? 0); const current = step ?? innerStep; const state = useMemo<StepsState>( () => ({ step: current, count, setStep: (next) => { setInnerStep(next); onStepChange?.({ step: next }); }, }), // eslint-disable-next-line react-hooks/exhaustive-deps [current, count], ); const element = useRender({ defaultTagName: "div", render: render as useRender.RenderProp, state: v, props: mergeProps({ ...variantDataAttrs(v) }, rest), }); return <StepsContext.Provider value={state}>{element}</StepsContext.Provider>;}
export type StepsItemProps = WithStyleOverrides<useRender.ComponentProps<"li">> & { /** Zero-based index of this step. */ index: number;};
function ItemNode({ render, index, ...rest}: Omit<StepsItemProps, "palette" | "style">) { const steps = useContext(StepsContext); const element = useRender({ defaultTagName: "li", render: render as useRender.RenderProp, state: {}, props: mergeProps( { "aria-current": steps.step === index ? ("step" as const) : undefined }, rest, ), }); return <ItemContext.Provider value={index}>{element}</ItemContext.Provider>;}
function TriggerNode(allProps: useRender.ComponentProps<"button">) { const { render, ...rest } = allProps; const index = useContext(ItemContext); const steps = useContext(StepsContext); return useRender({ defaultTagName: "button", render: render as useRender.RenderProp, state: {}, props: mergeProps( { type: "button" as const, onClick: () => steps.setStep(index) }, rest, ), });}
function IndicatorNode(allProps: useRender.ComponentProps<"span">) { const { render, ...rest } = allProps; const index = useContext(ItemContext); const steps = useContext(StepsContext); return useRender({ defaultTagName: "span", render: render as useRender.RenderProp, state: {}, props: mergeProps( { "data-complete": index < steps.step ? "" : undefined, "data-current": index === steps.step ? "" : undefined, }, rest, ), });}
function SeparatorNode(allProps: useRender.ComponentProps<"span">) { const { render, ...rest } = allProps; const index = useContext(ItemContext); const steps = useContext(StepsContext); return useRender({ defaultTagName: "span", render: render as useRender.RenderProp, state: {}, props: mergeProps( { "aria-hidden": true, "data-complete": index < steps.step ? "" : undefined, "data-last": index === steps.count - 1 ? "" : undefined, }, rest, ), });}
function IconNode(allProps: useRender.ComponentProps<"svg">) { const { render, ...rest } = allProps; return useRender({ defaultTagName: "svg", render: render as useRender.RenderProp, state: {}, props: mergeProps({ "aria-hidden": true }, rest), });}
export type StepsContentProps = WithStyleOverrides<useRender.ComponentProps<"div">> & { /** Zero-based index of the step this panel belongs to. */ index: number;};
function ContentNode({ render, index, ...rest}: Omit<StepsContentProps, "palette" | "style">) { const steps = useContext(StepsContext); const element = useRender({ defaultTagName: "div", render: render as useRender.RenderProp, state: {}, props: rest, }); return steps.step === index ? element : null;}
export const Steps = { Root: withProvider(RootNode, "root", "StepsRoot"), List: withContext( "ol", (v) => [...resolveSlotStyles(stepsSlotRecipe.slots.list, v), chrome.list], "StepsList", ), Item: withContext(ItemNode, "item", "StepsItem"), Trigger: withContext( TriggerNode, (v) => [...resolveSlotStyles(stepsSlotRecipe.slots.trigger, v), chrome.trigger], "StepsTrigger", ), Indicator: withContext( IndicatorNode, (v) => [ ...resolveSlotStyles(stepsSlotRecipe.slots.indicator, v), chrome.indicator, ], "StepsIndicator", ), Separator: withContext( SeparatorNode, (v) => [ ...resolveSlotStyles(stepsSlotRecipe.slots.separator, v), chrome.separatorComplete, chrome.separatorLast, ], "StepsSeparator", ), Title: withContext("span", "title", "StepsTitle"), Description: withContext("span", "description", "StepsDescription"), Icon: withContext(IconNode, () => [stepsIndicatorIcon.base], "StepsIcon"), Content: withContext(ContentNode, "content", "StepsContent"),};