PlStepper
A process the reader is moving through, and where they are in it. The steps are buttons, the current one owns a panel, and pressing one moves the reader.
import { PlStep, PlStepper } from 'plass-ui';
<PlStepper active={step} onActiveChange={setStep}>
<PlStep label="Account">…</PlStep>
<PlStep label="Verify">…</PlStep>
<PlStep label="Profile" optional>
…
</PlStep>
</PlStepper>;import 'package:plass_ui/plass_ui.dart';
PlStepper(
active: step,
onActiveChanged: (int next) => setState(() => step = next),
steps: const <PlStep>[
PlStep(label: Text('Account'), child: Text('…')),
PlStep(label: Text('Verify'), child: Text('…')),
PlStep(label: Text('Profile'), optional: Text('Optional'), child: Text('…')),
],
);Props
| Prop | Type | Default | Description |
|---|---|---|---|
| active | number | — | The index of the step the reader is on. Use with onActiveChange for a controlled stepper |
| defaultActive | number | 0 | The step it starts on, for an uncontrolled one |
| onActiveChange | (active: number) => void | — | Called when a step is pressed |
| linear | boolean | true | Whether a step ahead of the current one can be jumped to. A step behind is always reachable |
| orientationshared | PlassResponsive<'horizontal' | 'vertical'> | 'horizontal' | Horizontal puts the panel under the rail; vertical puts each one inside its own step |
| connector | 'solid' | 'dashed' | 'dotted' | 'none' | 'solid' | The line between two steps. none leaves the gap open |
| sizeshared | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'md' | Bullet and type scale |
| colorshared | 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info' | 'primary' | The family the rail takes |
| densityshared | 'default' | 'compact' | 'default' | The space between steps, and nothing else |
| Prop | Type | Default | Description |
|---|---|---|---|
| steps * | List<PlStep> | — | The steps, in order |
| active * | int | — | The index of the step the reader is on. Use with onActiveChange for a controlled stepper |
| onActiveChanged | ValueChanged<int>? | — | Called with the step that was pressed. A null handler makes every step inert, which is how a stepper is shown without being driven |
| linear | bool | true | Whether a step ahead of the current one can be jumped to. A step behind is always reachable |
| orientationshared | PlassResponsive<PlassOrientation> | PlassOrientation.horizontal | Horizontal puts the panel under the rail; vertical puts each one inside its own step |
| sizeshared | PlassSize | PlassSize.md | Bullet and type scale |
| colorshared | PlassColor | PlassColor.primary | The family the rail takes |
| densityshared | PlassDensity | PlassDensity.standard | The space between steps, and nothing else |
PlStep
| Prop | Type | Default | Description |
|---|---|---|---|
| label | ReactNode | — | What the step is called |
| description | ReactNode | — | A second line under it — what the step asks for |
| bullet | ReactNode | — | What is drawn in the bullet. The step's own number by default, and a tick once it is complete |
| status | 'complete' | 'current' | 'upcoming' | — | Overrides where the sequence says this step is. For the one that failed validation while the reader moved on |
| optional | boolean | ReactNode | — | Marks the step skippable. true draws the word "Optional"; a node draws that node |
| disabled | boolean | false | Cannot be reached, whatever linear says |
| colorshared | 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info' | — | Overrides the stepper's family for this one step |
| children | ReactNode | — | The panel this step shows while it is the current one |
| Prop | Type | Default | Description |
|---|---|---|---|
| label | Widget? | — | What the step is called |
| description | Widget? | — | A second line under it — what the step asks for |
| bullet | Widget? | — | What is drawn in the bullet. The step's own number by default, and a tick once it is complete |
| status | PlStepStatus? | — | Overrides where the sequence says this step is. For the one that failed validation while the reader moved on |
| optional | Widget? | — | Marks the step skippable, and says so in these words. There is no default string, because the package ships no translations and a word it invented would be in one language |
| disabled | bool | false | Cannot be reached, whatever linear says |
| colorshared | PlassColor | — | Overrides the stepper's family for this one step |
| connector | PlassStepConnector | PlassStepConnector.solid | How the line to the next step is drawn. none leaves the gap open |
| child | Widget? | — | The panel this step shows while it is the current one |
Every native <div> attribute passes through to the stepper, and every <li> attribute to a step. What the shared axes mean across the library is in prop conventions.
The steps are a list rather than children, for the reason PlTimeline's are: the stepper has to reason about them (which one is complete is arithmetic on an index, and which one can be reached is arithmetic on the same index), and neither question can be asked of an opaque Widget. That also settles the sharp edge the React build has to warn about: there is no way to hand it a wrapper that holds three steps.
optional takes a Widget rather than a bool, because there is no default string to fall back to: the package ships no translations, and a word it invented would be in one language.
Stepper or timeline
They draw the same rail (the same three bullet states, the same connector), and share it in the source, because a haloed bullet must not mean two things. The difference is what each one is for:
PlTimeline | Reports. A sequence that already happened, as text. Nothing on it can be pressed |
PlStepper | Is the sequence. The steps are buttons, the current one owns a panel, and the reader is inside it |
If nothing on it should be clickable, it is a timeline.
Examples
active
An index, not a value, exactly as a timeline's is. A stepper has no selection. Everything before it is complete, the step at it is current, everything after it is ahead.
Uncontrolled with defaultActive, or controlled with active and onActiveChange, which is what a form wizard wants: the Next button is the caller's, and so is the validation that decides whether it moves.
linear
On by default, and it is what makes this a process rather than a row of tabs: the third step of a sign-up cannot be filled in before the second. A step behind the reader is always reachable. Going back to correct an answer is the whole reason a stepper is not a wizard with one door.
Turn it off for a review screen, where every step has been answered and the reader is going back to check one.
<PlStepper active={3} linear={false}>
…
</PlStepper>orientation
Horizontal puts the panel under the whole rail. Vertical puts each step's panel inside the step, which is the reason to lay one out vertically at all: the answer sits under the question rather than under the rail.
It is responsive, so a set can run one way on a phone and the other on a laptop. A server renders the xs entry and the browser corrects it on hydration.It is resolved against the window's width during build, so the first frame is already right. See breakpoints.
import { useState } from 'react';
import { PlButton, PlStep, PlStepper, PlTypography } from 'plass-ui';
export default function StepperVertical() {
const [active, setActive] = useState(0);
return (
<div className="w-full max-w-md">
<PlStepper orientation="vertical" active={active} onActiveChange={setActive}>
{[
['Pick a plan', 'Ten seats on the team plan.'],
['Add a card', 'Charged on the first of the month.'],
['Invite the team', 'You can do this later.']
].map(([label, body], index) => (
<PlStep key={label} label={label}>
<div className="flex flex-col items-start gap-2">
<PlTypography level="body">{body}</PlTypography>
<PlButton size="sm" onClick={() => setActive(index + 1)} disabled={index === 2}>
Continue
</PlButton>
</div>
</PlStep>
))}
</PlStepper>
</div>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
class StepperVertical extends StatefulWidget {
const StepperVertical({super.key});
@override
State<StepperVertical> createState() => _StepperVerticalState();
}
class _StepperVerticalState extends State<StepperVertical> {
int _active = 0;
@override
Widget build(BuildContext context) {
return SizedBox(
width: 380,
child: PlStepper(
orientation: const PlassResponsive<PlassOrientation>(PlassOrientation.vertical),
active: _active,
onActiveChanged: (int next) => setState(() => _active = next),
steps: <PlStep>[
for (final (int index, (String label, String body)) in <(String, String)>[
('Pick a plan', 'Ten seats on the team plan.'),
('Add a card', 'Charged on the first of the month.'),
('Invite the team', 'You can do this later.'),
].indexed)
PlStep(
label: Text(label),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Text(body),
const SizedBox(height: 8),
PlButton(
size: PlassSize.sm,
onPressed: index == 2 ? null : () => setState(() => _active = index + 1),
child: const Text('Continue'),
),
],
),
),
],
),
);
}
}status and color
active decides all three states, and status overrides one of them. That is for the step that failed validation while the reader was three steps further on. It is current again without the stepper moving, and color="danger" says why.
import { PlStep, PlStepper } from 'plass-ui';
export default function StepperStatus() {
return (
<div className="w-full max-w-2xl">
{/* The reader has moved on, and the second step failed validation behind
them. `status` and `color` say so without moving `active`. */}
<PlStepper active={2} linear={false}>
<PlStep label="Account" />
<PlStep label="Verify" description="Code expired" status="current" color="danger" />
<PlStep label="Profile" />
</PlStepper>
</div>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
class StepperStatus extends StatelessWidget {
const StepperStatus({super.key});
@override
Widget build(BuildContext context) {
return SizedBox(
width: 560,
// The reader has moved on, and the second step failed validation behind
// them. `status` and `color` say so without moving `active`.
child: PlStepper(
active: 2,
linear: false,
onActiveChanged: (int _) {},
steps: const <PlStep>[
PlStep(label: Text('Account')),
PlStep(
label: Text('Verify'),
description: Text('Code expired'),
status: PlStepStatus.current,
color: PlassColor.danger,
),
PlStep(label: Text('Profile')),
],
),
);
}
}optional
true draws the word "Optional". A node draws that node instead, which is how the word is translated. There is no optionalLabel prop, because one prop that takes both is one prop.
<PlStep label="Profile" optional="건너뛸 수 있음" />Notes
The steps have to be the stepper's own children. It numbers them by walking them, so a component of your own that returns three steps is one child holding three, and every step in it would be step one. Build the list with
.map()or an array (both are flattened) rather than with a wrapper component.
- A conditional step that rendered nothing does not shift the numbering of the ones after it.
- A step outside a stepper still renders. It is one step with nothing before or after it.
The steps are a list, so there is no wrapper to get wrong. See the note above the props table.
Accessibility
- A real
<ol>of<li>s, and the current step carriesaria-current="step". - It is deliberately not a
role="tablist". A tab list owes a keyboard reader one tab stop and arrow keys, and a screen reader a panel per tab; a stepper is a sequence of separate controls, and claiming the role without the behaviour is worse than never claiming it. Each reachable step is its own tab stop, which is what a stepper's steps are. - A step that cannot be reached is not a button at all, rather than a disabled one. There is nothing there to press yet.
- The panel is named by the step it belongs to, so a screen reader landing in it is told which step it is the panel for.
The current step is marked selected, which is the nearest thing the framework has to aria-current="step". A step that cannot be reached is not a button. It is a plain box, rather than a disabled one.