Stepper
A multi-step flow header: numbered step indicators with connectors and completed/current/upcoming states over the current step's panel with back/continue actions.
Installation
Blocks are copied source, not library calls — the generator writes this
exact template into app/views/blocks/
and from there it's yours to edit. The preview below renders the same
source the copy-in produces.
bin/rails g poetry:block stepper
Default
Payment
Card details are collected by the payment element that mounts here; this panel is the step's content slot.
<%# Stepper is a BLOCK, not a component: neither shadcn nor Base UI
ships one, so the pattern lives here as copy-in source - adapt the
steps freely; nothing hides behind an API. The server owns step state:
pass step: as a local, or let it ride ?step= - Back/Continue are plain
links that re-render, and completed steps are links back. In a real
flow, swap the query param for your wizard's own routes/model state
and the final action for a form submit. %>
<% steps = ["Shipping", "Payment", "Review"] %>
<% step = (local_assigns[:step] || params[:step] || 2).to_i.clamp(1, steps.length) %>
<% panels = {
1 => ["Shipping", "Step 1 of 3 - where this order lands.",
"Address fields mount here; this panel is the step's content slot."],
2 => ["Payment", "Step 2 of 3 - how this order gets paid.",
"Card details are collected by the payment element that mounts here; this panel is the step's content slot."],
3 => ["Review", "Step 3 of 3 - confirm and place the order.",
"The order summary renders here - line items, shipping choice, and the charge that Place order will submit."]
} %>
<% title, description, body = panels.fetch(step) %>
<div class="mx-auto max-w-2xl p-6">
<nav aria-label="Progress">
<ol class="flex items-center gap-3">
<% steps.each_with_index do |label, index| %>
<% number = index + 1 %>
<% unless number == 1 %>
<li aria-hidden="true" class="h-px min-w-8 flex-1 <%= number <= step ? "bg-primary" : "bg-border" %>"></li>
<% end %>
<% if number < step %>
<%# Completed: filled indicator with a check, revisitable. %>
<li class="flex items-center">
<a href="?step=<%= number %>" class="flex items-center gap-3 rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring">
<span class="flex size-8 items-center justify-center rounded-full bg-primary text-primary-foreground">
<%= poetry_icon(name: :check, class: "size-4", label: "Completed") %>
</span>
<span class="text-sm font-medium"><%= label %></span>
</a>
</li>
<% elsif number == step %>
<%# Current: outlined in primary, named by aria-current. %>
<li aria-current="step" class="flex items-center gap-3">
<span class="flex size-8 items-center justify-center rounded-full border-2 border-primary text-sm font-semibold text-primary"><%= number %></span>
<span class="text-sm font-medium"><%= label %></span>
</li>
<% else %>
<%# Upcoming: muted and inert until reached. %>
<li class="flex items-center gap-3">
<span class="flex size-8 items-center justify-center rounded-full border border-border text-sm text-muted-foreground"><%= number %></span>
<span class="text-sm text-muted-foreground"><%= label %></span>
</li>
<% end %>
<% end %>
</ol>
</nav>
<div class="mt-6">
<%= poetry_card do |card| %>
<% card.with_title { title } %>
<% card.with_description { description } %>
<div class="space-y-4 text-sm">
<p><%= body %></p>
</div>
<% card.with_footer do %>
<div class="flex w-full items-center justify-between">
<% if step > 1 %>
<%= poetry_button(tag: :a, href: "?step=#{step - 1}", variant: :outline) { "Back" } %>
<% else %>
<span></span>
<% end %>
<% if step < steps.length %>
<%= poetry_button(tag: :a, href: "?step=#{step + 1}") { "Continue to #{steps[step].downcase}" } %>
<% else %>
<%# The terminal action: wire this to your submit. %>
<%= poetry_button { "Place order" } %>
<% end %>
</div>
<% end %>
<% end %>
</div>
</div>
Styling
A block has no styling surface of its own — every element in the copied
template is a Poetry component carrying its usual
data-slot parts, and because you own
the source, utility classes can be edited directly in place. Per-part
contracts live on the composed components' pages: