Shows progress through a multi-step flow such as a checkout, an onboarding wizard, or a form split across pages, and lets people move between the steps.
import { Stepper } from 'frontile';
Stepper yields a List of Steps and, optionally, a Panel for each one.
Steps are matched to panels by @value, never by position, so steps can be
conditional, looped, or wrapped in your own components. Without @value the
Stepper tracks the current step itself, starting on @defaultValue.
The yielded next, prev, hasNext and hasPrev move between steps from
anywhere inside the Stepper, such as a button in a panel.
import { Stepper, Button } from 'frontile';
<template>
<div class='demo-stack demo-stack--wide'>
<Stepper @defaultValue='account' as |S|>
<S.List @label='Checkout progress'>
<S.Step @value='account' @title='Account' @description='Your details' />
<S.Step @value='billing' @title='Billing' @description='Payment' />
<S.Step @value='review' @title='Review' @description='Confirm' />
</S.List>
<S.Panel @value='account'>Create your account.</S.Panel>
<S.Panel @value='billing'>Add a payment method.</S.Panel>
<S.Panel @value='review'>Review your order.</S.Panel>
<div class='flex gap-2'>
<Button @variant='outline' @onPress={{S.prev}}>Back</Button>
<Button @color='primary' @onPress={{S.next}}>Continue</Button>
</div>
</Stepper>
</div>
</template>
@orientation='vertical' stacks the steps in a column and switches the arrow
keys that move between them to up and down. Titles are always visible when
vertical, so it suits long titles, many steps, and narrow screens.
import { Stepper } from 'frontile';
<template>
<div class='demo-stack'>
<Stepper @defaultValue='billing' @orientation='vertical' as |S|>
<S.List @label='Checkout progress'>
<S.Step @value='account' @title='Account' @description='Your details' />
<S.Step @value='billing' @title='Billing' @description='Payment' />
<S.Step @value='review' @title='Review' @description='Confirm' />
</S.List>
</Stepper>
</div>
</template>
@color sets the accent for complete and active steps and the filled
connector between them. It defaults to primary. @size scales the indicator
and its text.
import { Stepper } from 'frontile';
import { array } from '@ember/helper';
<template>
<div class='demo-stack'>
{{#each
(array
'neutral' 'primary' 'secondary' 'tertiary' 'success' 'warning' 'danger'
)
as |color|
}}
<Stepper
@defaultValue='b'
@color={{color}}
@labelPosition='bottom'
as |S|
>
<S.List @label='{{color}} stepper'>
<S.Step @value='a' @title='One' />
<S.Step @value='b' @title='Two' />
<S.Step @value='c' @title='Three' />
</S.List>
</Stepper>
{{/each}}
</div>
</template>
import { Stepper } from 'frontile';
import { array } from '@ember/helper';
<template>
<div class='demo-stack'>
{{#each (array 'sm' 'md' 'lg') as |size|}}
<Stepper
@defaultValue='b'
@size={{size}}
@labelPosition='bottom'
as |S|
>
<S.List @label='{{size}} stepper'>
<S.Step @value='a' @title='One' />
<S.Step @value='b' @title='Two' />
<S.Step @value='c' @title='Three' />
</S.List>
</Stepper>
{{/each}}
</div>
</template>
@labelPosition='end' (the default) puts each title and description beside its
indicator. @labelPosition='bottom' centers them underneath, which fits
longer titles and narrower containers. Vertical Steppers ignore it.
import { Stepper } from 'frontile';
<template>
<div class='demo-stack'>
<Stepper @defaultValue='billing' @labelPosition='bottom' as |S|>
<S.List @label='Checkout progress'>
<S.Step @value='account' @title='Account' @description='Your details' />
<S.Step @value='billing' @title='Billing' @description='Payment' />
<S.Step @value='review' @title='Review' @description='Confirm' />
</S.List>
</Stepper>
</div>
</template>
@icon takes an icon component, shown in the indicator while the step is
active or upcoming. Complete steps show a check, and invalid and loading steps
show their own marks. Passing a block named indicator replaces the
indicator's content entirely.
import { Stepper } from 'frontile';
import { UserIcon, SettingsIcon, CheckIcon } from 'site/components/icons';
<template>
<div class='demo-stack'>
<Stepper @defaultValue='profile' @labelPosition='bottom' as |S|>
<S.List @label='Setup progress'>
<S.Step @value='profile' @title='Profile' @icon={{UserIcon}} />
<S.Step @value='preferences' @title='Preferences' @icon={{SettingsIcon}} />
<S.Step @value='done' @title='Done' @icon={{CheckIcon}} />
</S.List>
</Stepper>
</div>
</template>
@isLoading swaps the indicator for a spinner while something is in flight,
and @isInvalid marks a step that needs attention. An invalid step is always
danger-colored, whatever @color is. Neither changes which step is selected.
import { Stepper } from 'frontile';
<template>
<div class='demo-stack'>
<Stepper @defaultValue='review' @labelPosition='bottom' as |S|>
<S.List @label='Checkout progress'>
<S.Step @value='account' @title='Account' />
<S.Step @value='billing' @title='Billing' @isInvalid={{true}} />
<S.Step @value='review' @title='Review' @isLoading={{true}} />
<S.Step @value='confirm' @title='Confirm' />
</S.List>
</Stepper>
</div>
</template>
A step counts as complete when it comes before the selected step. When
completion is a business rule rather than position, pass @isComplete on the
step and it takes precedence. The active step is always active.
import { Stepper } from 'frontile';
<template>
<div class='demo-stack'>
<Stepper @defaultValue='b' @labelPosition='bottom' as |S|>
<S.List @label='Onboarding'>
<S.Step @value='a' @title='Verify email' @isComplete={{false}} />
<S.Step @value='b' @title='Add team' />
<S.Step @value='c' @title='Invite' @isComplete={{true}} />
</S.List>
</Stepper>
</div>
</template>
@isDisabled on a step stops people selecting it: clicks are ignored, arrow
keys skip it, and it is announced as disabled. Each step's rule is yours, so
any flow can be expressed. This one lets people go back to any step they have
already reached, but not skip ahead.
next and prev skip disabled steps, so a button that moves to a step that is
still disabled calls goTo, or sets @value itself as below. goTo can reach
a disabled step because @isDisabled governs what people do, not what your
code does.
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { Stepper, Button } from 'frontile';
const STEPS = ['account', 'billing', 'review'];
export default class StepperBackOnlyExample extends Component {
@tracked step = 'account';
@tracked furthest = 0;
isAhead = (value: string): boolean => STEPS.indexOf(value) > this.furthest;
setStep = (value: string): void => {
this.step = value;
this.furthest = Math.max(this.furthest, STEPS.indexOf(value));
};
continue = (): void => {
const next = STEPS[STEPS.indexOf(this.step) + 1];
if (next) {
this.setStep(next);
}
};
<template>
<div class='demo-stack'>
<Stepper
@value={{this.step}}
@onChange={{this.setStep}}
@labelPosition='bottom'
as |S|
>
<S.List @label='Checkout progress'>
<S.Step
@value='account'
@title='Account'
@isDisabled={{this.isAhead 'account'}}
/>
<S.Step
@value='billing'
@title='Billing'
@isDisabled={{this.isAhead 'billing'}}
/>
<S.Step
@value='review'
@title='Review'
@isDisabled={{this.isAhead 'review'}}
/>
</S.List>
<div class='flex gap-2'>
<Button @color='primary' @onPress={{this.continue}}>Continue</Button>
</div>
</Stepper>
</div>
</template>
}
The mode is decided by whether @value is passed, not by what it holds. Omit
it and Stepper keeps the current step itself, with @onChange still firing
so you can observe it. Pass it, even as undefined, and the selection only
follows what you pass: @onChange becomes a request, and your handler decides
whether the step actually changes.
That makes a controlled Stepper the place to validate before moving on.
Whether a step is available is always your call: the Stepper cannot know what
"terms accepted" means. Pass the condition to @isDisabled so the step looks
and behaves unavailable (styled, skipped by arrow keys, ignored on click), and
keep the @onChange check as the guard for anything that moves the step
without going through the step itself.
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { Stepper, Checkbox } from 'frontile';
export default class StepperValidatedExample extends Component {
@tracked step = 'account';
@tracked accepted = false;
get planLocked(): boolean {
return !this.accepted;
}
setStep = (value: string): void => {
if (this.step === 'account' && value !== 'account' && !this.accepted) {
return;
}
this.step = value;
};
setAccepted = (value: boolean): void => {
this.accepted = value;
};
<template>
<div class='demo-stack'>
<Stepper
@value={{this.step}}
@onChange={{this.setStep}}
@labelPosition='bottom'
as |S|
>
<S.List @label='Sign up'>
<S.Step @value='account' @title='Account' />
<S.Step @value='plan' @title='Plan' @isDisabled={{this.planLocked}} />
</S.List>
<S.Panel @value='account'>
<Checkbox
@label='Accept the terms to continue'
@checked={{this.accepted}}
@onChange={{this.setAccepted}}
/>
</S.Panel>
<S.Panel @value='plan'>Pick a plan.</S.Panel>
</Stepper>
</div>
</template>
}
The Stepper only owns the current step, and only when uncontrolled. Resetting
a flow means going back to the first step with goTo (or setting @value)
and clearing your own form state and flags.
import { fn } from '@ember/helper';
import { Stepper, Button } from 'frontile';
<template>
<div class='demo-stack'>
<Stepper @defaultValue='review' @labelPosition='bottom' as |S|>
<S.List @label='Checkout progress'>
<S.Step @value='account' @title='Account' />
<S.Step @value='billing' @title='Billing' />
<S.Step @value='review' @title='Review' />
</S.List>
<div class='flex gap-2'>
<Button @variant='outline' @onPress={{fn S.goTo 'account'}}>Start over</Button>
</div>
</Stepper>
</div>
</template>
Only the selected step's panel is rendered, so a form inside another step's
panel is torn down when you move away and starts empty when you return. Move
the form's state into a parent, or set @keepMounted on the Panel to keep
its content in the DOM, hidden, while another step is selected.
A kept-mounted panel keeps running its own effects while hidden, and an autofocus inside it fires when it first renders, not when its step is selected.
import { Stepper, Input } from 'frontile';
<template>
<div class='demo-stack'>
<Stepper @defaultValue='account' @labelPosition='bottom' as |S|>
<S.List @label='Sign up'>
<S.Step @value='account' @title='Account' />
<S.Step @value='plan' @title='Plan' />
</S.List>
<S.Panel @value='account' @keepMounted={{true}}>
<Input @label='Name' />
</S.Panel>
<S.Panel @value='plan'>Pick a plan.</S.Panel>
</Stepper>
</div>
</template>
A panel's content can live in its own component. Type its argument with
StepperApi and pass the yielded object, or just the helper you need.
import Component from '@glimmer/component';
import { on } from '@ember/modifier';
import type { StepperApi } from 'frontile';
interface Signature {
Args: { stepper: StepperApi<string> };
}
export default class BillingStep extends Component<Signature> {
<template>
<button type='button' {{on 'click' @stepper.next}}>Continue</button>
</template>
}
<S.Panel @value='billing'>
<BillingStep @stepper={{S}} />
</S.Panel>
A controlled Stepper works with the router: pass the route name as @value
and move with your own transition in @onChange. The URL stays the source of
truth, so back, forward and deep links work. Leave out the panels and render
the route's outlet instead.
<Stepper
@value={{this.router.currentRouteName}}
@onChange={{this.goToStep}}
as |S|
>
<S.List @label='Checkout progress'>
<S.Step @value='checkout.account' @title='Account' />
<S.Step @value='checkout.billing' @title='Billing' />
<S.Step @value='checkout.review' @title='Review' />
</S.List>
</Stepper>
{{outlet}}
A horizontal Stepper measures its own width, not the screen's, so it adapts inside modals and sidebars as well as pages. Give it a parent with a definite width, such as a block element or a full-width flex item: a parent that shrinks to fit its content leaves it no width to measure.
When the row does not fit, @collapse decides what happens. It defaults to
'menu': the row is replaced by a button showing the current step ("Step 2 of
4" and its title). Pressing it opens the steps as a floating list, laid out
like a vertical Stepper, over the content below. Choosing a step selects it
and closes the list.
@labelPosition |
Full row shows from |
|---|---|
bottom |
28rem (448px) |
end |
42rem (672px) |
The box below starts narrow enough to collapse. Drag the handle at its right edge to widen it and watch the full row return.
import { Stepper } from 'frontile';
<template>
<div class='demo-stack demo-stack--wide'>
<div
class='overflow-hidden rounded-lg border border-neutral-soft p-4'
style='resize: horizontal; width: 20rem; min-width: 14rem; max-width: 100%'
>
<Stepper @defaultValue='billing' @labelPosition='bottom' as |S|>
<S.List @label='Checkout progress'>
<S.Step @value='account' @title='Account' />
<S.Step @value='billing' @title='Billing' />
<S.Step @value='review' @title='Review' />
<S.Step @value='confirm' @title='Confirm' />
</S.List>
</Stepper>
</div>
</div>
</template>
The list is a native popover, so it renders in the top layer: an ancestor's
overflow: hidden, like the box above, does not clip it. Escape and clicking
away close it, and focus returns to the button. The steps stay in the DOM while
the list is closed, so next and prev work throughout.
Set @collapse='summary' to keep the row at narrow widths instead. Only the
indicators and connectors show, with the "Step 2 of 4" line and the active
step's title beneath them. Every title stays available to screen readers.
Wider than the threshold, the titles appear and the line goes.
import { Stepper } from 'frontile';
<template>
<div class='demo-stack demo-stack--wide'>
<div
class='overflow-hidden rounded-lg border border-neutral-soft p-4'
style='resize: horizontal; width: 20rem; min-width: 14rem; max-width: 100%'
>
<Stepper
@defaultValue='billing'
@labelPosition='bottom'
@collapse='summary'
as |S|
>
<S.List @label='Checkout progress'>
<S.Step @value='account' @title='Account' />
<S.Step @value='billing' @title='Billing' />
<S.Step @value='review' @title='Review' />
<S.Step @value='confirm' @title='Confirm' />
</S.List>
</Stepper>
</div>
</div>
</template>
Narrow screens fit roughly six or seven steps as indicators alone; beyond that, prefer the menu or a vertical Stepper. Vertical Steppers never collapse.
Set @collapse={{false}} to keep the full row, titles included, at every
width: no menu and no summary line. The row does not shrink below the width
its titles need, and narrower than that it overflows its container, so it
suits short titles and few steps. Drag the box below narrower to see where
that happens.
import { Stepper } from 'frontile';
<template>
<div class='demo-stack demo-stack--wide'>
<div
class='overflow-hidden rounded-lg border border-neutral-soft p-4'
style='resize: horizontal; width: 20rem; min-width: 14rem; max-width: 100%'
>
<Stepper
@defaultValue='billing'
@labelPosition='bottom'
@collapse={{false}}
as |S|
>
<S.List @label='Checkout progress'>
<S.Step @value='account' @title='Account' />
<S.Step @value='billing' @title='Billing' />
<S.Step @value='review' @title='Review' />
<S.Step @value='confirm' @title='Confirm' />
</S.List>
</Stepper>
</div>
</div>
</template>
The two widths are theme tokens. Redefine them in your stylesheet's @theme
block to move them for the whole app. They are named for Stepper so that
redefining Tailwind's own --container-* sizes does not affect them.
@theme {
--container-fr-stepper-bottom: 24rem;
--container-fr-stepper-end: 36rem;
}
For a single Stepper, set @collapse={{false}} and write your own rules on
the slots.
The look is not fixed. @classes adds classes to any slot (list, item,
step, indicator, title, connector and the rest), merged with the
theme's, and the indicator block replaces what the circle shows. Every slot
is inside the step's <li>, which carries data-state (complete, active
or upcoming), so group-data-[state=active]/item: styles a slot for one
state.
This one drops the circles for plain icons, makes the active step a filled
pill, and shortens the connectors to fixed dashes. The ! on the indicator
classes is needed because the theme colors it per state.
import { Stepper } from 'frontile';
import {
SearchIcon,
PackageIcon,
StarIcon,
UserIcon,
CheckIcon
} from 'site/components/icons';
const classes = {
item: 'flex-none',
step: [
'min-h-9 gap-2 rounded-full px-3 py-0',
'group-data-[state=active]/item:bg-primary',
// The icon follows the step's color through `text-current` below.
'group-data-[state=active]/item:text-on-primary',
'group-data-[state=complete]/item:hover:bg-surface-overlay-soft',
'group-data-[state=upcoming]/item:hover:bg-surface-overlay-soft'
].join(' '),
indicator: 'size-auto border-0 bg-transparent! text-current! [&_svg]:size-4',
title: 'group-data-[state=active]/item:text-on-primary',
connector: 'mx-2 h-px w-4 min-w-0 flex-none bg-neutral-mild after:hidden'
};
<template>
<div class='demo-stack demo-stack--wide'>
<Stepper
@defaultValue='plan'
@collapse={{false}}
@classes={{classes}}
as |S|
>
<S.List @label='Order progress' class='justify-center'>
<S.Step @value='providers' @title='Providers'>
<:indicator><SearchIcon /></:indicator>
</S.Step>
<S.Step @value='plan' @title='Plan'>
<:indicator><PackageIcon /></:indicator>
</S.Step>
<S.Step @value='add-ons' @title='Add-ons'>
<:indicator><StarIcon /></:indicator>
</S.Step>
<S.Step @value='account' @title='Account'>
<:indicator><UserIcon /></:indicator>
</S.Step>
<S.Step @value='checkout' @title='Checkout'>
<:indicator><CheckIcon /></:indicator>
</S.Step>
</S.List>
</Stepper>
</div>
</template>
min-h-9 makes the pill 36px tall, under the theme's 44px hit area. That
still clears the 24px WCAG 2.2 AA minimum, but keep it in mind on touch
screens. For changes to every Stepper in an app, edit the stepper theme
instead of passing @classes each time.
Stepper renders an ordered list (List) of items, each holding a button.
The selected step has aria-current="step". It is not a tab list: steps do
not need a panel, and moving between them does not have to change content.
Stepper.List needs an accessible name from @label, or pass
aria-labelledby on it directly. Each Panel is a role="region" labelled by
its step. Loading steps set aria-busy, and invalid steps are announced as
"Invalid" in addition to being colored.
When collapsed to a menu, the button is a disclosure (aria-expanded,
aria-controls) whose text names the current step; the list inside is the same
list of buttons, so aria-current and the keys below apply unchanged, with the
up and down arrows. While the menu is closed the list is hidden from assistive
technology entirely, so the button's text is how the current step is
announced, and Tab reaches the button rather than the steps. With @collapse='summary', titles are visually hidden but
still read aloud, and the "Step N of M" line is hidden from assistive
technology so the current step is not announced twice.
| Key | Behaviour |
|---|---|
Tab |
Moves focus into or out of the steps. Only one step is a tab stop. |
ArrowRight / ArrowDown |
Moves focus to the next enabled step, wrapping at the end. |
ArrowLeft / ArrowUp |
Moves focus to the previous enabled step, wrapping at the start. |
Home / End |
Moves focus to the first / last enabled step. |
Enter / Space |
Selects the focused step. |
Arrow keys move focus without selecting, so passing a step never triggers validation or navigation. Horizontal orientation uses left and right, vertical uses up and down. Disabled steps are skipped.
Each step's clickable area is at least 44px square at every size.
The text the Stepper adds is English by default. Pass @invalidLabel on a step
to change what screen readers announce for an invalid step, and
@summaryLabel on the Stepper, a function taking the position and total, to
change the "Step 2 of 4" line.
Element: HTMLDivElement
| Name | Type | Default | Description |
|---|---|---|---|
classes
|
SlotsToClasses<'label' | 'trigger' | 'base' | 'item' | 'description' | 'title' | 'indicator' | 'list' | 'summary' | 'step' | 'check' | 'connector' | 'probe' | 'triggerLabel' | 'chevron' | 'summaryText' | ... 4 more ... | 'panel'>
|
- | Class names for each slot of the component, merged with the theme's. |
collapse
|
enum
|
'menu'
|
What a horizontal Stepper does when its container is too narrow for the full row:
Vertical Steppers never collapse. |
color
|
enum
|
'primary'
|
The accent used for complete and active steps and the filled connector. Invalid steps are always danger-colored, whatever this is. |
defaultValue
|
T
|
- | Sets the initially selected value when uncontrolled. Without it, the first enabled step starts selected. |
labelPosition
|
enum
|
'end'
|
Where a step's title sits relative to its indicator: beside it (end) or
centered underneath (bottom). Ignored when vertical.
|
onChange
|
function
|
- |
Called with the newly selected value when the user picks a step, and
when next, prev or goTo move the selection. In controlled mode the
selection only moves if this handler updates @value.
|
orientation
|
enum
|
'horizontal'
|
Lays the steps out in a row or a column, and switches the arrow keys that move between them to match. |
size
|
enum
|
'md'
|
The size of the step indicators and their text. |
summaryLabel
|
function
|
(position, total) => `Step ${position} of ${total}`
|
Formats the "Step N of M" text shown in the menu trigger or the summary line. Use it to localize the text. |
value
|
T
|
- |
The currently selected step's value. Compared against each step's
Passing this argument at all puts the component in controlled mode --
passing it as |
| Name | Type | Default | Description |
|---|---|---|---|
default
*
|
Array
|
- |
Element: HTMLOListElement
| Name | Type | Default | Description |
|---|---|---|---|
context
*
|
Object
|
- | Supplied by Stepper. Not part of the public API. |
label
|
string
|
- | Accessible name for the list of steps. |
| Name | Type | Default | Description |
|---|---|---|---|
default
*
|
Array
|
- |
Element: HTMLButtonElement
| Name | Type | Default | Description |
|---|---|---|---|
context
*
|
Object
|
- | Supplied by Stepper. Not part of the public API. |
value
*
|
T
|
- | The value this step represents. Steps are matched by value, never by position, so they can be conditional, looped or wrapped in components. |
class
|
string
|
- | Class names appended to this step's theme classes. |
description
|
string
|
- | Supporting text shown beneath or beside the title. |
icon
|
ComponentLike<{ Element: SVGElement; }>
|
- | An icon component shown in the indicator while the step is active or upcoming. Complete, invalid and loading steps show their own marks. |
invalidLabel
|
string
|
'Invalid'
|
The text announced to screen readers for an invalid step. Use it to localize the announcement. |
isComplete
|
boolean
|
- | Overrides whether the step counts as complete. Omit it and a step is complete when it comes before the selected step. Pass it when completion is business logic rather than position. |
isDisabled
|
boolean
|
false
|
Prevents the user from selecting this step: clicks are ignored and arrow keys skip over it. |
isInvalid
|
boolean
|
false
|
Marks the step as having a problem: a danger-colored indicator with a
screen-reader-only "Invalid" announcement. Always danger, whatever the
Stepper's @color.
|
isLoading
|
boolean
|
false
|
Shows a spinner in place of the indicator content and sets aria-busy.
|
title
|
string
|
- | The step's title. |
| Name | Type | Default | Description |
|---|---|---|---|
indicator
*
|
Array
|
- | Replaces the content of the indicator circle. |
label
*
|
Array
|
- | Replaces the default title and description. |
Element: HTMLDivElement
| Name | Type | Default | Description |
|---|---|---|---|
context
*
|
Object
|
- | Supplied by Stepper. Not part of the public API. |
value
*
|
T
|
- |
The value this panel is paired with; must match a Step's @value.
|
class
|
string
|
- | Class names appended to this panel's theme classes. |
keepMounted
|
boolean
|
false
|
Keeps the panel's content in the DOM, hidden, while its step is not the selected one. Use it to preserve a form's local state across steps. Without it only the selected step's panel renders. A kept-mounted panel keeps running its own effects while hidden, and anything in it with an autofocus fires on mount whether or not the step is selected. |
| Name | Type | Default | Description |
|---|---|---|---|
default
*
|
Array
|
- |