Stepper
UI::StepperThe steps of a multi-step form, each one done, current or still to come.
# VelvetKit
## Stepper (`UI::Stepper`)
The steps of a multi-step form, each one done, current or still to come.
Add it: `bin/rails generate velvetkit:add stepper`
Also adds: `icon`
Stimulus: `ui--stepper`
### Arguments
```ruby
UI::Stepper.new(current: 1, orientation: :horizontal, variant: :default, align: :start, layout: :stacked, connector: true, labels: true, navigable: nil, label: nil, class: nil, **html_attrs)
```
Slots: `with_step`, `with_completion`
### Notes
The steps of a multi-step flow, each one done, current, still to come, or in
trouble. `current:` is the 1-based number of the step someone is on: steps
before it are done, and `current: steps + 1` marks them all done. A step can
set its own state with `status:` (:complete, :current, :upcoming, :error or
:loading), for one that failed or is still being checked.
Looks: `variant: :default` draws numbered circles and `:dot` small dots.
Horizontal steppers put each title under its step, `align: :start` (at the
left of it) or `:center`; `orientation: :vertical` puts it beside, with room
for a description and more. `connector: false` drops the lines, and
`labels: false` keeps the titles for screen readers only. Where it has less
than 28rem (a phone, a narrow card), a horizontal stepper shows just its
steps, with "Step 2 of 4" and the current title under them.
A step's block is its panel: what someone fills in on that step. Give the
steps panels and the stepper moves between them in the browser; a button
with `data-action="ui--stepper#next"` checks the panel's fields and moves
on, marking the step as an error if one is invalid, and `ui--stepper#previous`
goes back, as does clicking a done step (`navigable: false` turns that
off). Moving on is only ever by Continue, so a panel is never skipped
unchecked. Give a button `data-ui--stepper-target="next"` or `"previous"` and
it hides when there's nowhere to go, and `"submit"` shows only on the last
step. Panels sit under the steps; `layout: :side` puts a vertical stepper
beside them, given the room, and `layout: :inline` puts each one under its
step's title. The stepper's own block comes after the panels, for those
buttons; `with_completion` is shown once every step is done.
To validate on the server instead, render the stepper with the step the
server says someone is on and post each panel's form. While a form inside
the stepper is submitting, its current step shows a spinner.
Give a done step an `href:` to let people go back to it. Screen readers hear
each step's state, and the current one has aria-current="step".
### Example: Horizontal
```erb
<%= render(UI::Card.new(class: "w-full max-w-3xl")) do |card| %>
<% card.with_header do |header| %>
<%= render(UI::Card::Title.new) { "Price calculator" } %>
<%= render(UI::Card::Description.new) { "All printing includes full colour on both sides." } %>
<% end %>
<% card.with_content do %>
<%= render(UI::Stepper.new(current: 3, label: "Price calculator")) do |stepper| %>
<% stepper.with_step(title: "Size and style") %>
<% stepper.with_step(title: "Paper type") %>
<% stepper.with_step(title: "Quantity") %>
<% stepper.with_step(title: "Packs") %>
<% end %>
<% end %>
<% end %>
```
### Example: Centered, with Descriptions
```erb
<%# A status the server knows: no panels, no buttons, just where the order is. %>
<div class="w-full max-w-3xl">
<%= render(UI::Stepper.new(current: 3, align: :center, label: "Order #4021")) do |stepper| %>
<% stepper.with_step(title: "Ordered", description: "Mon, 2 Nov") %>
<% stepper.with_step(title: "Packed", description: "Tue, 3 Nov") %>
<% stepper.with_step(title: "Shipped", description: "Left Mumbai hub") %>
<% stepper.with_step(title: "Out for delivery") %>
<% stepper.with_step(title: "Delivered", description: "By Fri, 6 Nov") %>
<% end %>
</div>
```
### Example: Dots
```erb
<div class="w-full max-w-4xl space-y-10">
<%= render(UI::Stepper.new(current: 3, variant: :dot, align: :center, label: "Contractor onboarding")) do |stepper| %>
<% ["General info", "Payment info", "Benefits and compliance", "Review and sign", "Contractor signs", "Activated"].each do |title| %>
<% stepper.with_step(title: title) %>
<% end %>
<% end %>
<%= render(UI::Stepper.new(current: 2, variant: :dot, label: "Checkout")) do |stepper| %>
<% stepper.with_step(title: "Cart") %>
<% stepper.with_step(title: "Shipping") %>
<% stepper.with_step(title: "Payment") %>
<% end %>
</div>
```
### Example: Vertical
```erb
<%# `status: :loading` for a step that's waiting on someone else. %>
<div class="w-full max-w-sm">
<%= render(UI::Stepper.new(orientation: :vertical, current: 3, label: "Leave request approvals")) do |stepper| %>
<% stepper.with_step(title: "Team lead", description: "Approved by Aditi Rao · 15 Jun 2026") %>
<% stepper.with_step(title: "Head of department", description: "Approved by Farhan Shah · 15 Jun 2026") %>
<% stepper.with_step(title: "VP, People", description: "Waiting since 16 Jun 2026", status: :loading) %>
<% stepper.with_step(title: "Payroll", description: "Updates your leave balance") %>
<% end %>
</div>
```
### Example: With an Error
```erb
<%# `status: :error` marks the step that failed; the description says why. %>
<div class="w-full max-w-md">
<%= render(UI::Stepper.new(orientation: :vertical, current: 2, label: "Deploy a1f9c3e")) do |stepper| %>
<% stepper.with_step(title: "Build", description: "Finished in 42s") %>
<% stepper.with_step(title: "Test", description: "3 of 214 tests failed in spec/billing", status: :error) %>
<% stepper.with_step(title: "Migrate database") %>
<% stepper.with_step(title: "Release", description: "To production") %>
<div class="flex gap-2 pl-11">
<%= render(UI::Button.new(size: :sm)) do |button| %><% button.with_leading_icon(name: "rotate-cw") %>Retry tests<% end %>
<%= render(UI::Button.new(variant: :outline, size: :sm)) { "View logs" } %>
</div>
<% end %>
</div>
```
### Example: Without Lines
```erb
<%# `connector: false` for steps that can be done in any order. Done ones link
back, so people can change what they set. %>
<div class="w-full max-w-md rounded-xl border bg-card p-6">
<h3 class="font-semibold">Set up your store</h3>
<p class="mb-6 text-sm text-muted-foreground">2 of 4 done</p>
<%= render(UI::Stepper.new(orientation: :vertical, connector: false, label: "Store setup")) do |stepper| %>
<% stepper.with_step(title: "Add your first product", description: "Linen shirt, ₹2,400", status: :complete, href: "#") %>
<% stepper.with_step(title: "Choose a theme", description: "Using Coast", status: :complete, href: "#") %>
<% stepper.with_step(title: "Set up payments", description: "Connect UPI or a card processor", status: :current, icon: "credit-card") %>
<% stepper.with_step(title: "Pick a domain", description: "Or keep yourstore.example.com", status: :upcoming, icon: "globe") %>
<% end %>
</div>
```
### Example: Wizard, Checked in the Browser
```erb
<%# One form, split into steps. Continue checks the fields on the current step
in the browser (required, pattern, type) and stays put, marking the step,
if one is wrong. Nothing is sent until "Create project". %>
<%= form_with url: "/demo/projects", class: "w-full max-w-3xl" do %>
<%= render(UI::Card.new) do |card| %>
<% card.with_content(class: "pt-6") do %>
<%= render(UI::Stepper.new(label: "Create a project")) do |stepper| %>
<% stepper.with_step(title: "Details", description: "Name and address") do %>
<div class="grid gap-4 sm:grid-cols-2">
<%= render(UI::Input.new(label: "Project name", name: "project[name]", required: true, placeholder: "Acme dashboard")) %>
<%# The address is the part after the slash, so the box shows the rest. %>
<%= render(UI::FormField.new(label: "Address", required: true, hint: "Lowercase letters, numbers and dashes")) do |field| %>
<%= render(UI::InputGroup.new) do |group| %>
<% group.with_addon(align: "inline-start") { "acme.app/" } %>
<% group.with_field(name: "project[slug]", required: true, pattern: "[a-z0-9]+(-[a-z0-9]+)*", placeholder: "dashboard",
title: "Lowercase letters, numbers and dashes, like sales-dashboard", autocapitalize: "none",
spellcheck: false, **field.control_attributes) %>
<% end %>
<% end %>
</div>
<% end %>
<% stepper.with_step(title: "Plan", description: "Pick what fits") do %>
<fieldset class="grid gap-3 sm:grid-cols-3">
<legend class="sr-only">Plan</legend>
<% [["hobby", "Hobby", "Free", "One member, 3 projects"],
["team", "Team", "$20 a month", "Up to 10 members"],
["business", "Business", "$80 a month", "Unlimited members, SSO"]].each do |value, name, price, detail| %>
<label class="relative flex cursor-pointer flex-col gap-1 rounded-lg border p-4 transition-colors hover:bg-accent/50 has-[:checked]:border-primary has-[:checked]:bg-primary/5 has-[:focus-visible]:ring-[3px] has-[:focus-visible]:ring-ring/40">
<input type="radio" name="project[plan]" value="<%= value %>" required class="peer sr-only">
<span class="text-sm font-medium"><%= name %></span>
<span class="text-lg font-semibold"><%= price %></span>
<span class="text-sm text-muted-foreground"><%= detail %></span>
</label>
<% end %>
</fieldset>
<% end %>
<% stepper.with_step(title: "Team", description: "Invite people") do %>
<%= render(UI::Input.new(type: :email, label: "Email addresses", name: "project[invites]", multiple: true,
placeholder: "dev@acme.com, ops@acme.com", hint: "Optional. Separate them with commas.")) %>
<% end %>
<% stepper.with_step(title: "Review", description: "Check and create") do %>
<%= render(UI::Alert.new(variant: :info)) do |alert| %>
<% alert.with_title { "Almost there" } %>
<% alert.with_description { "You can rename the project, change its URL or plan, and invite more people later, from Settings." } %>
<% end %>
<% end %>
<div class="mt-8 flex items-center gap-2 border-t pt-6">
<%= render(UI::Button.new(variant: :outline, hidden: true, data: { action: "ui--stepper#previous", "ui--stepper-target": "previous" })) do |button| %><% button.with_leading_icon(name: "arrow-left") %>Back<% end %>
<%= render(UI::Button.new(class: "ml-auto", data: { action: "ui--stepper#next", "ui--stepper-target": "next" })) do |button| %>Continue<% button.with_trailing_icon(name: "arrow-right") %><% end %>
<%= render(UI::Button.new(type: :submit, class: "ml-auto", hidden: true, data: { "ui--stepper-target": "submit" })) { "Create project" } %>
</div>
<% end %>
<% end %>
<% end %>
<% end %>
```
### Example: Beside the Form
```erb
<%# `layout: :side`: the steps in a column beside the panels, for a longer
flow. The last button moves on to the completion instead of posting. %>
<div class="w-full max-w-4xl rounded-xl border bg-card p-6 sm:p-8">
<%= render(UI::Stepper.new(orientation: :vertical, layout: :side, label: "Book a speaker")) do |stepper| %>
<% stepper.with_step(title: "Format", description: "In person or remote") do %>
<h3 class="text-lg font-semibold">How will the speaker join?</h3>
<p class="mb-5 text-sm text-muted-foreground">Remote talks can still be watched together in the office.</p>
<%= render(UI::RadioGroup.new(name: "booking[format]", value: "remote")) do |group| %>
<% group.with_item(value: "in_person", label: "In person, at our office") %>
<% group.with_item(value: "remote", label: "Remote, with the team in the office") %>
<% group.with_item(value: "all_remote", label: "Remote, everyone from home") %>
<% end %>
<% end %>
<% stepper.with_step(title: "Talk", description: "Title and audience") do %>
<div class="grid gap-4">
<%= render(UI::Input.new(label: "Title", name: "booking[title]", required: true, placeholder: "Scaling Postgres without sharding")) %>
<%= render(UI::NativeSelect.new(label: "Audience", name: "booking[audience]", required: true, prompt: "Choose one",
options: [["Engineering", "engineering"], ["Whole company", "company"], ["Leadership", "leadership"]])) %>
</div>
<% end %>
<% stepper.with_step(title: "Date and time", description: "When it happens") do %>
<div class="grid gap-4 sm:grid-cols-2">
<%= render(UI::Input.new(type: :date, label: "Date", name: "booking[date]", required: true)) %>
<%= render(UI::Input.new(type: :time, label: "Start", name: "booking[time]", required: true, value: "16:00")) %>
</div>
<% end %>
<% stepper.with_step(title: "Speaker", description: "Who to invite") do %>
<div class="grid gap-4 sm:grid-cols-2">
<%= render(UI::Input.new(label: "Name", name: "booking[speaker]", required: true)) %>
<%= render(UI::Input.new(type: :email, label: "Email", name: "booking[email]", required: true)) %>
</div>
<% end %>
<% stepper.with_completion do %>
<%= render(UI::EmptyState.new(icon: "circle-check", title: "Request sent", description: "We've emailed the speaker. You'll hear back when they accept.", class: "md:p-6")) do %>
<%= render(UI::Button.new(variant: :outline, data: { action: "ui--stepper#reset" })) { "Book another" } %>
<% end %>
<% end %>
<div class="mt-8 flex items-center gap-2">
<%= render(UI::Button.new(variant: :ghost, hidden: true, data: { action: "ui--stepper#previous", "ui--stepper-target": "previous" })) { "Back" } %>
<%= render(UI::Button.new(class: "ml-auto", data: { action: "ui--stepper#next", "ui--stepper-target": "next" })) { "Continue" } %>
</div>
<% end %>
</div>
```
### Example: Steps Inline
```erb
<%# `layout: :inline`: each panel opens under its own step, with its own
button, like a checklist that walks you through. %>
<div class="w-full max-w-lg">
<%= render(UI::Stepper.new(orientation: :vertical, layout: :inline, label: "Turn on two-factor")) do |stepper| %>
<% stepper.with_step(title: "Install an authenticator app", description: "1Password, Authy or Google Authenticator") do %>
<p class="mb-3 text-sm text-muted-foreground">Any app that makes six-digit codes works.</p>
<%= render(UI::Button.new(size: :sm, data: { action: "ui--stepper#next" })) { "I have one" } %>
<% end %>
<% stepper.with_step(title: "Scan the code", description: "Or type the key by hand") do %>
<div class="mb-3 flex items-center gap-4">
<div class="grid size-24 shrink-0 place-items-center rounded-md border bg-muted text-muted-foreground"><%= render(UI::Icon.new(name: "qr-code", size: :xl)) %></div>
<code class="font-mono text-sm break-all">JBSW Y3DP EHPK 3PXP</code>
</div>
<%= render(UI::Button.new(size: :sm, data: { action: "ui--stepper#next" })) { "Next" } %>
<% end %>
<% stepper.with_step(title: "Enter a code", description: "To check it's set up") do %>
<div class="flex items-end gap-2">
<%= render(UI::Input.new(label: "Six-digit code", name: "otp", required: true, pattern: "[0-9]{6}", inputmode: "numeric",
autocomplete: "one-time-code", maxlength: 6, placeholder: "123456", class: "w-36 font-mono")) %>
<%= render(UI::Button.new(data: { action: "ui--stepper#next" })) { "Verify" } %>
</div>
<% end %>
<% stepper.with_completion do %>
<%= render(UI::Alert.new(variant: :success)) do |alert| %>
<% alert.with_icon(name: "shield-check") %>
<% alert.with_title { "Two-factor is on" } %>
<% alert.with_description { "You'll be asked for a code when you sign in on a new device." } %>
<% end %>
<% end %>
<% end %>
</div>
```
### Example: Dots in a Card
```erb
<%# Dots, and `labels: false`: the heading on each panel says where you are. %>
<div class="w-full max-w-xl rounded-2xl border bg-card p-6 sm:p-8">
<%= render(UI::Stepper.new(variant: :dot, labels: false, label: "Form your LLC")) do |stepper| %>
<% stepper.with_step(title: "State") do %>
<h3 class="text-2xl font-semibold tracking-tight">Choose the state to incorporate your LLC</h3>
<p class="mt-6 mb-3 text-xs font-medium tracking-wide text-muted-foreground uppercase">Recommended</p>
<fieldset class="grid gap-3">
<legend class="sr-only">State</legend>
<% [["wyoming", "Wyoming", true, "Lower annual costs. No corporate income tax or franchise tax."],
["delaware", "Delaware", false, "Strong legal protection for shareholders. Popular with C-corps."]].each do |value, name, recommended, detail| %>
<label class="flex cursor-pointer flex-col gap-1.5 rounded-lg border p-4 transition-colors hover:bg-accent/50 has-[:checked]:border-primary has-[:checked]:bg-primary/5 has-[:focus-visible]:ring-[3px] has-[:focus-visible]:ring-ring/40">
<input type="radio" name="llc[state]" value="<%= value %>" required class="sr-only">
<span class="flex items-center gap-2 font-medium"><%= name %><% if recommended %><%= render(UI::Badge.new(variant: :outline, size: :sm, class: "border-emerald-300 text-emerald-700 dark:border-emerald-800 dark:text-emerald-300")) { "Recommended" } %><% end %></span>
<span class="text-sm text-muted-foreground"><%= detail %></span>
</label>
<% end %>
</fieldset>
<% end %>
<% stepper.with_step(title: "Name") do %>
<h3 class="mb-6 text-2xl font-semibold tracking-tight">What's your company called?</h3>
<%= render(UI::Input.new(label: "Company name", name: "llc[name]", required: true, placeholder: "Northwind Studio", hint: "We'll add “LLC” to the end.")) %>
<% end %>
<% stepper.with_step(title: "Members") do %>
<h3 class="mb-6 text-2xl font-semibold tracking-tight">How many owners?</h3>
<%= render(UI::Input.new(type: :number, label: "Members", name: "llc[members]", required: true, min: 1, max: 50, value: 1, class: "w-28")) %>
<% end %>
<% stepper.with_step(title: "Review") do %>
<h3 class="mb-2 text-2xl font-semibold tracking-tight">Ready to file</h3>
<p class="text-sm text-muted-foreground">We'll file with the state today. Most states approve in 3 to 5 business days.</p>
<% end %>
<% stepper.with_completion do %>
<%= render(UI::EmptyState.new(icon: "circle-check", title: "Filed", description: "We'll email you when the state approves it.", class: "md:p-6")) do %>
<%= render(UI::Button.new(variant: :outline, data: { action: "ui--stepper#reset" })) { "Start over" } %>
<% end %>
<% end %>
<div class="mt-8 flex flex-col gap-2">
<%= render(UI::Button.new(size: :lg, full_width: true, data: { action: "ui--stepper#next", "ui--stepper-target": "next" })) { "Continue" } %>
<%= render(UI::Button.new(variant: :ghost, full_width: true, hidden: true, data: { action: "ui--stepper#previous", "ui--stepper-target": "previous" })) { "Back" } %>
</div>
<% end %>
</div>
```
### Example: Server-Driven, with Links Back
```erb
<%# The server decides the step. Each panel is a form that posts to it: valid,
it redirects to the next step; invalid, it renders this again with
`status: :error` and the field's error, with a 422. Done steps link back.
While the form is sending, the step shows a spinner. %>
<div class="w-full max-w-2xl">
<%= render(UI::Stepper.new(current: 2, label: "Sign up")) do |stepper| %>
<% stepper.with_step(title: "Account", description: "ada@example.com", href: "#account") %>
<% stepper.with_step(title: "Workspace", description: "That address is taken", status: :error) do %>
<%= form_with url: "/demo/signup/workspace", method: :patch, class: "grid gap-4" do %>
<%= render(UI::Input.new(label: "Workspace URL", name: "workspace[slug]", value: "acme", required: true,
error: "acme.example.com is taken. Try acme-hq or acme-labs.")) %>
<div class="flex justify-end gap-2">
<%= render(UI::Button.new(type: :submit)) { "Continue" } %>
</div>
<% end %>
<% end %>
<% stepper.with_step(title: "Plan") %>
<% stepper.with_step(title: "Invite") %>
<% end %>
</div>
```
bin/rails generate velvetkit:add stepper
- Also adds
- Icon
- Stimulus
- ui--stepper
- Installs
- Nothing
Live demo
Talks to a serverEach Continue posts the step to this site, which checks it and sends back the next one, or the same one with its errors. Try ada@example.com, which already has an account, or the workspace address acme, which is taken.
Examples
Horizontal
Price calculator
All printing includes full colour on both sides.
price_calculator.html.erb
<%= render(UI::Card.new(class: "w-full max-w-3xl")) do |card| %>
<% card.with_header do |header| %>
<%= render(UI::Card::Title.new) { "Price calculator" } %>
<%= render(UI::Card::Description.new) { "All printing includes full colour on both sides." } %>
<% end %>
<% card.with_content do %>
<%= render(UI::Stepper.new(current: 3, label: "Price calculator")) do |stepper| %>
<% stepper.with_step(title: "Size and style") %>
<% stepper.with_step(title: "Paper type") %>
<% stepper.with_step(title: "Quantity") %>
<% stepper.with_step(title: "Packs") %>
<% end %>
<% end %>
<% end %>
Centered, with Descriptions
order_tracking.html.erb
<%# A status the server knows: no panels, no buttons, just where the order is. %>
<div class="w-full max-w-3xl">
<%= render(UI::Stepper.new(current: 3, align: :center, label: "Order #4021")) do |stepper| %>
<% stepper.with_step(title: "Ordered", description: "Mon, 2 Nov") %>
<% stepper.with_step(title: "Packed", description: "Tue, 3 Nov") %>
<% stepper.with_step(title: "Shipped", description: "Left Mumbai hub") %>
<% stepper.with_step(title: "Out for delivery") %>
<% stepper.with_step(title: "Delivered", description: "By Fri, 6 Nov") %>
<% end %>
</div>
Dots
onboarding.html.erb
<div class="w-full max-w-4xl space-y-10">
<%= render(UI::Stepper.new(current: 3, variant: :dot, align: :center, label: "Contractor onboarding")) do |stepper| %>
<% ["General info", "Payment info", "Benefits and compliance", "Review and sign", "Contractor signs", "Activated"].each do |title| %>
<% stepper.with_step(title: title) %>
<% end %>
<% end %>
<%= render(UI::Stepper.new(current: 2, variant: :dot, label: "Checkout")) do |stepper| %>
<% stepper.with_step(title: "Cart") %>
<% stepper.with_step(title: "Shipping") %>
<% stepper.with_step(title: "Payment") %>
<% end %>
</div>
Vertical
approvals.html.erb
<%# `status: :loading` for a step that's waiting on someone else. %>
<div class="w-full max-w-sm">
<%= render(UI::Stepper.new(orientation: :vertical, current: 3, label: "Leave request approvals")) do |stepper| %>
<% stepper.with_step(title: "Team lead", description: "Approved by Aditi Rao · 15 Jun 2026") %>
<% stepper.with_step(title: "Head of department", description: "Approved by Farhan Shah · 15 Jun 2026") %>
<% stepper.with_step(title: "VP, People", description: "Waiting since 16 Jun 2026", status: :loading) %>
<% stepper.with_step(title: "Payroll", description: "Updates your leave balance") %>
<% end %>
</div>
With an Error
failed_deploy.html.erb
<%# `status: :error` marks the step that failed; the description says why. %>
<div class="w-full max-w-md">
<%= render(UI::Stepper.new(orientation: :vertical, current: 2, label: "Deploy a1f9c3e")) do |stepper| %>
<% stepper.with_step(title: "Build", description: "Finished in 42s") %>
<% stepper.with_step(title: "Test", description: "3 of 214 tests failed in spec/billing", status: :error) %>
<% stepper.with_step(title: "Migrate database") %>
<% stepper.with_step(title: "Release", description: "To production") %>
<div class="flex gap-2 pl-11">
<%= render(UI::Button.new(size: :sm)) do |button| %><% button.with_leading_icon(name: "rotate-cw") %>Retry tests<% end %>
<%= render(UI::Button.new(variant: :outline, size: :sm)) { "View logs" } %>
</div>
<% end %>
</div>
Without Lines
Set up your store
2 of 4 done
checklist.html.erb
<%# `connector: false` for steps that can be done in any order. Done ones link
back, so people can change what they set. %>
<div class="w-full max-w-md rounded-xl border bg-card p-6">
<h3 class="font-semibold">Set up your store</h3>
<p class="mb-6 text-sm text-muted-foreground">2 of 4 done</p>
<%= render(UI::Stepper.new(orientation: :vertical, connector: false, label: "Store setup")) do |stepper| %>
<% stepper.with_step(title: "Add your first product", description: "Linen shirt, ₹2,400", status: :complete, href: "#") %>
<% stepper.with_step(title: "Choose a theme", description: "Using Coast", status: :complete, href: "#") %>
<% stepper.with_step(title: "Set up payments", description: "Connect UPI or a card processor", status: :current, icon: "credit-card") %>
<% stepper.with_step(title: "Pick a domain", description: "Or keep yourstore.example.com", status: :upcoming, icon: "globe") %>
<% end %>
</div>
Wizard, Checked in the Browser
wizard.html.erb
<%# One form, split into steps. Continue checks the fields on the current step
in the browser (required, pattern, type) and stays put, marking the step,
if one is wrong. Nothing is sent until "Create project". %>
<%= form_with url: "/demo/projects", class: "w-full max-w-3xl" do %>
<%= render(UI::Card.new) do |card| %>
<% card.with_content(class: "pt-6") do %>
<%= render(UI::Stepper.new(label: "Create a project")) do |stepper| %>
<% stepper.with_step(title: "Details", description: "Name and address") do %>
<div class="grid gap-4 sm:grid-cols-2">
<%= render(UI::Input.new(label: "Project name", name: "project[name]", required: true, placeholder: "Acme dashboard")) %>
<%# The address is the part after the slash, so the box shows the rest. %>
<%= render(UI::FormField.new(label: "Address", required: true, hint: "Lowercase letters, numbers and dashes")) do |field| %>
<%= render(UI::InputGroup.new) do |group| %>
<% group.with_addon(align: "inline-start") { "acme.app/" } %>
<% group.with_field(name: "project[slug]", required: true, pattern: "[a-z0-9]+(-[a-z0-9]+)*", placeholder: "dashboard",
title: "Lowercase letters, numbers and dashes, like sales-dashboard", autocapitalize: "none",
spellcheck: false, **field.control_attributes) %>
<% end %>
<% end %>
</div>
<% end %>
<% stepper.with_step(title: "Plan", description: "Pick what fits") do %>
<fieldset class="grid gap-3 sm:grid-cols-3">
<legend class="sr-only">Plan</legend>
<% [["hobby", "Hobby", "Free", "One member, 3 projects"],
["team", "Team", "$20 a month", "Up to 10 members"],
["business", "Business", "$80 a month", "Unlimited members, SSO"]].each do |value, name, price, detail| %>
<label class="relative flex cursor-pointer flex-col gap-1 rounded-lg border p-4 transition-colors hover:bg-accent/50 has-[:checked]:border-primary has-[:checked]:bg-primary/5 has-[:focus-visible]:ring-[3px] has-[:focus-visible]:ring-ring/40">
<input type="radio" name="project[plan]" value="<%= value %>" required class="peer sr-only">
<span class="text-sm font-medium"><%= name %></span>
<span class="text-lg font-semibold"><%= price %></span>
<span class="text-sm text-muted-foreground"><%= detail %></span>
</label>
<% end %>
</fieldset>
<% end %>
<% stepper.with_step(title: "Team", description: "Invite people") do %>
<%= render(UI::Input.new(type: :email, label: "Email addresses", name: "project[invites]", multiple: true,
placeholder: "dev@acme.com, ops@acme.com", hint: "Optional. Separate them with commas.")) %>
<% end %>
<% stepper.with_step(title: "Review", description: "Check and create") do %>
<%= render(UI::Alert.new(variant: :info)) do |alert| %>
<% alert.with_title { "Almost there" } %>
<% alert.with_description { "You can rename the project, change its URL or plan, and invite more people later, from Settings." } %>
<% end %>
<% end %>
<div class="mt-8 flex items-center gap-2 border-t pt-6">
<%= render(UI::Button.new(variant: :outline, hidden: true, data: { action: "ui--stepper#previous", "ui--stepper-target": "previous" })) do |button| %><% button.with_leading_icon(name: "arrow-left") %>Back<% end %>
<%= render(UI::Button.new(class: "ml-auto", data: { action: "ui--stepper#next", "ui--stepper-target": "next" })) do |button| %>Continue<% button.with_trailing_icon(name: "arrow-right") %><% end %>
<%= render(UI::Button.new(type: :submit, class: "ml-auto", hidden: true, data: { "ui--stepper-target": "submit" })) { "Create project" } %>
</div>
<% end %>
<% end %>
<% end %>
<% end %>
Beside the Form
sidebar.html.erb
<%# `layout: :side`: the steps in a column beside the panels, for a longer
flow. The last button moves on to the completion instead of posting. %>
<div class="w-full max-w-4xl rounded-xl border bg-card p-6 sm:p-8">
<%= render(UI::Stepper.new(orientation: :vertical, layout: :side, label: "Book a speaker")) do |stepper| %>
<% stepper.with_step(title: "Format", description: "In person or remote") do %>
<h3 class="text-lg font-semibold">How will the speaker join?</h3>
<p class="mb-5 text-sm text-muted-foreground">Remote talks can still be watched together in the office.</p>
<%= render(UI::RadioGroup.new(name: "booking[format]", value: "remote")) do |group| %>
<% group.with_item(value: "in_person", label: "In person, at our office") %>
<% group.with_item(value: "remote", label: "Remote, with the team in the office") %>
<% group.with_item(value: "all_remote", label: "Remote, everyone from home") %>
<% end %>
<% end %>
<% stepper.with_step(title: "Talk", description: "Title and audience") do %>
<div class="grid gap-4">
<%= render(UI::Input.new(label: "Title", name: "booking[title]", required: true, placeholder: "Scaling Postgres without sharding")) %>
<%= render(UI::NativeSelect.new(label: "Audience", name: "booking[audience]", required: true, prompt: "Choose one",
options: [["Engineering", "engineering"], ["Whole company", "company"], ["Leadership", "leadership"]])) %>
</div>
<% end %>
<% stepper.with_step(title: "Date and time", description: "When it happens") do %>
<div class="grid gap-4 sm:grid-cols-2">
<%= render(UI::Input.new(type: :date, label: "Date", name: "booking[date]", required: true)) %>
<%= render(UI::Input.new(type: :time, label: "Start", name: "booking[time]", required: true, value: "16:00")) %>
</div>
<% end %>
<% stepper.with_step(title: "Speaker", description: "Who to invite") do %>
<div class="grid gap-4 sm:grid-cols-2">
<%= render(UI::Input.new(label: "Name", name: "booking[speaker]", required: true)) %>
<%= render(UI::Input.new(type: :email, label: "Email", name: "booking[email]", required: true)) %>
</div>
<% end %>
<% stepper.with_completion do %>
<%= render(UI::EmptyState.new(icon: "circle-check", title: "Request sent", description: "We've emailed the speaker. You'll hear back when they accept.", class: "md:p-6")) do %>
<%= render(UI::Button.new(variant: :outline, data: { action: "ui--stepper#reset" })) { "Book another" } %>
<% end %>
<% end %>
<div class="mt-8 flex items-center gap-2">
<%= render(UI::Button.new(variant: :ghost, hidden: true, data: { action: "ui--stepper#previous", "ui--stepper-target": "previous" })) { "Back" } %>
<%= render(UI::Button.new(class: "ml-auto", data: { action: "ui--stepper#next", "ui--stepper-target": "next" })) { "Continue" } %>
</div>
<% end %>
</div>
Steps Inline
inline.html.erb
<%# `layout: :inline`: each panel opens under its own step, with its own
button, like a checklist that walks you through. %>
<div class="w-full max-w-lg">
<%= render(UI::Stepper.new(orientation: :vertical, layout: :inline, label: "Turn on two-factor")) do |stepper| %>
<% stepper.with_step(title: "Install an authenticator app", description: "1Password, Authy or Google Authenticator") do %>
<p class="mb-3 text-sm text-muted-foreground">Any app that makes six-digit codes works.</p>
<%= render(UI::Button.new(size: :sm, data: { action: "ui--stepper#next" })) { "I have one" } %>
<% end %>
<% stepper.with_step(title: "Scan the code", description: "Or type the key by hand") do %>
<div class="mb-3 flex items-center gap-4">
<div class="grid size-24 shrink-0 place-items-center rounded-md border bg-muted text-muted-foreground"><%= render(UI::Icon.new(name: "qr-code", size: :xl)) %></div>
<code class="font-mono text-sm break-all">JBSW Y3DP EHPK 3PXP</code>
</div>
<%= render(UI::Button.new(size: :sm, data: { action: "ui--stepper#next" })) { "Next" } %>
<% end %>
<% stepper.with_step(title: "Enter a code", description: "To check it's set up") do %>
<div class="flex items-end gap-2">
<%= render(UI::Input.new(label: "Six-digit code", name: "otp", required: true, pattern: "[0-9]{6}", inputmode: "numeric",
autocomplete: "one-time-code", maxlength: 6, placeholder: "123456", class: "w-36 font-mono")) %>
<%= render(UI::Button.new(data: { action: "ui--stepper#next" })) { "Verify" } %>
</div>
<% end %>
<% stepper.with_completion do %>
<%= render(UI::Alert.new(variant: :success)) do |alert| %>
<% alert.with_icon(name: "shield-check") %>
<% alert.with_title { "Two-factor is on" } %>
<% alert.with_description { "You'll be asked for a code when you sign in on a new device." } %>
<% end %>
<% end %>
<% end %>
</div>
Dots in a Card
dot_wizard.html.erb
<%# Dots, and `labels: false`: the heading on each panel says where you are. %>
<div class="w-full max-w-xl rounded-2xl border bg-card p-6 sm:p-8">
<%= render(UI::Stepper.new(variant: :dot, labels: false, label: "Form your LLC")) do |stepper| %>
<% stepper.with_step(title: "State") do %>
<h3 class="text-2xl font-semibold tracking-tight">Choose the state to incorporate your LLC</h3>
<p class="mt-6 mb-3 text-xs font-medium tracking-wide text-muted-foreground uppercase">Recommended</p>
<fieldset class="grid gap-3">
<legend class="sr-only">State</legend>
<% [["wyoming", "Wyoming", true, "Lower annual costs. No corporate income tax or franchise tax."],
["delaware", "Delaware", false, "Strong legal protection for shareholders. Popular with C-corps."]].each do |value, name, recommended, detail| %>
<label class="flex cursor-pointer flex-col gap-1.5 rounded-lg border p-4 transition-colors hover:bg-accent/50 has-[:checked]:border-primary has-[:checked]:bg-primary/5 has-[:focus-visible]:ring-[3px] has-[:focus-visible]:ring-ring/40">
<input type="radio" name="llc[state]" value="<%= value %>" required class="sr-only">
<span class="flex items-center gap-2 font-medium"><%= name %><% if recommended %><%= render(UI::Badge.new(variant: :outline, size: :sm, class: "border-emerald-300 text-emerald-700 dark:border-emerald-800 dark:text-emerald-300")) { "Recommended" } %><% end %></span>
<span class="text-sm text-muted-foreground"><%= detail %></span>
</label>
<% end %>
</fieldset>
<% end %>
<% stepper.with_step(title: "Name") do %>
<h3 class="mb-6 text-2xl font-semibold tracking-tight">What's your company called?</h3>
<%= render(UI::Input.new(label: "Company name", name: "llc[name]", required: true, placeholder: "Northwind Studio", hint: "We'll add “LLC” to the end.")) %>
<% end %>
<% stepper.with_step(title: "Members") do %>
<h3 class="mb-6 text-2xl font-semibold tracking-tight">How many owners?</h3>
<%= render(UI::Input.new(type: :number, label: "Members", name: "llc[members]", required: true, min: 1, max: 50, value: 1, class: "w-28")) %>
<% end %>
<% stepper.with_step(title: "Review") do %>
<h3 class="mb-2 text-2xl font-semibold tracking-tight">Ready to file</h3>
<p class="text-sm text-muted-foreground">We'll file with the state today. Most states approve in 3 to 5 business days.</p>
<% end %>
<% stepper.with_completion do %>
<%= render(UI::EmptyState.new(icon: "circle-check", title: "Filed", description: "We'll email you when the state approves it.", class: "md:p-6")) do %>
<%= render(UI::Button.new(variant: :outline, data: { action: "ui--stepper#reset" })) { "Start over" } %>
<% end %>
<% end %>
<div class="mt-8 flex flex-col gap-2">
<%= render(UI::Button.new(size: :lg, full_width: true, data: { action: "ui--stepper#next", "ui--stepper-target": "next" })) { "Continue" } %>
<%= render(UI::Button.new(variant: :ghost, full_width: true, hidden: true, data: { action: "ui--stepper#previous", "ui--stepper-target": "previous" })) { "Back" } %>
</div>
<% end %>
</div>
Server-Driven, with Links Back
server_driven.html.erb
<%# The server decides the step. Each panel is a form that posts to it: valid,
it redirects to the next step; invalid, it renders this again with
`status: :error` and the field's error, with a 422. Done steps link back.
While the form is sending, the step shows a spinner. %>
<div class="w-full max-w-2xl">
<%= render(UI::Stepper.new(current: 2, label: "Sign up")) do |stepper| %>
<% stepper.with_step(title: "Account", description: "ada@example.com", href: "#account") %>
<% stepper.with_step(title: "Workspace", description: "That address is taken", status: :error) do %>
<%= form_with url: "/demo/signup/workspace", method: :patch, class: "grid gap-4" do %>
<%= render(UI::Input.new(label: "Workspace URL", name: "workspace[slug]", value: "acme", required: true,
error: "acme.example.com is taken. Try acme-hq or acme-labs.")) %>
<div class="flex justify-end gap-2">
<%= render(UI::Button.new(type: :submit)) { "Continue" } %>
</div>
<% end %>
<% end %>
<% stepper.with_step(title: "Plan") %>
<% stepper.with_step(title: "Invite") %>
<% end %>
</div>
API
Arguments
UI::Stepper.new(current: 1, orientation: :horizontal, variant: :default, align: :start, layout: :stacked, connector: true, labels: true, navigable: nil, label: nil, class: nil, **html_attrs)
Slots:
with_step
with_completion
Notes
The steps of a multi-step flow, each one done, current, still to come, or in
trouble. `current:` is the 1-based number of the step someone is on: steps
before it are done, and `current: steps + 1` marks them all done. A step can
set its own state with `status:` (:complete, :current, :upcoming, :error or
:loading), for one that failed or is still being checked.
Looks: `variant: :default` draws numbered circles and `:dot` small dots.
Horizontal steppers put each title under its step, `align: :start` (at the
left of it) or `:center`; `orientation: :vertical` puts it beside, with room
for a description and more. `connector: false` drops the lines, and
`labels: false` keeps the titles for screen readers only. Where it has less
than 28rem (a phone, a narrow card), a horizontal stepper shows just its
steps, with "Step 2 of 4" and the current title under them.
A step's block is its panel: what someone fills in on that step. Give the
steps panels and the stepper moves between them in the browser; a button
with `data-action="ui--stepper#next"` checks the panel's fields and moves
on, marking the step as an error if one is invalid, and `ui--stepper#previous`
goes back, as does clicking a done step (`navigable: false` turns that
off). Moving on is only ever by Continue, so a panel is never skipped
unchecked. Give a button `data-ui--stepper-target="next"` or `"previous"` and
it hides when there's nowhere to go, and `"submit"` shows only on the last
step. Panels sit under the steps; `layout: :side` puts a vertical stepper
beside them, given the room, and `layout: :inline` puts each one under its
step's title. The stepper's own block comes after the panels, for those
buttons; `with_completion` is shown once every step is done.
To validate on the server instead, render the stepper with the step the
server says someone is on and post each panel's form. While a form inside
the stepper is submitting, its current step shows a spinner.
Give a done step an `href:` to let people go back to it. Screen readers hear
each step's state, and the current one has aria-current="step".