Steps
A multi-step wizard component with Turbo Frame integration for smooth step transitions, supporting horizontal, vertical, and minimal layout variants.
Key Features
- Seven Variants - Horizontal, vertical, minimal, progress, breadcrumb, timeline, and vertical cards
- Layout Slots - Flexible header, footer, and navigation slots for full customization
- Flexible API - Positional or keyword title syntax for cleaner code
- Navigation Modes - Linear, free navigation, or completed-only
- Step States - Completed (checkmark), active (highlighted), pending, and error
- Turbo Frame Integration - Smooth step transitions without full page reloads
- LocalStorage Caching - Instant UI restore on page reload
- Client-Side Validation - HTML5 validation before step transitions
- Error State Handling - Display server-side validation errors on steps
- Keyboard Navigation - Arrow keys, Home, End, Enter, and Space
- Accessibility - Full ARIA support following WAI-ARIA Wizard patterns
- JavaScript API - Programmatic control with Stimulus actions and events
Basic Usage
Create a multi-step wizard using the block syntax. Each step is defined with a title and content. Try the interactive demo below!
<turbo-frame id="registration-frame">
<%= rui_steps(
id: "registration-wizard",
url: wizards_path,
current_step: @current_step,
turbo_frame_id: frame_id,
variant: :horizontal,
navigation: :linear
) do |steps| %>
<% steps.with_step(title: "Account") do %>
<div class="py-6 space-y-4 max-w-md">
<%= form_with(local: true) do |f| %>
<%= f.rui_input(:email, label: "Email", type: :email, required: true) %>
<%= f.rui_input(:password, label: "Password", type: :password, required: true) %>
<% end %>
</div>
<% end %>
<% steps.with_step(title: "Profile") do %>
<div class="py-6 space-y-4 max-w-md">
<%= form_with(local: true) do |f| %>
<%= f.rui_input(:full_name, label: "Full Name", required: true) %>
<%= f.rui_textarea(:bio, label: "Bio", rows: 4) %>
<% end %>
</div>
<% end %>
<% steps.with_step(title: "Confirm") do %>
<div class="py-6 max-w-md">
<p class="text-zinc-600 dark:text-zinc-400 mb-4">Review your information and click Submit to complete registration.</p>
</div>
<% end %>
<% end %>
</turbo-frame>
Variants
Choose from seven layout variants based on your use case and available space. Each variant is optimized for different workflow patterns and screen sizes.
Horizontal (Default)
Progress indicators at top, content below. Best for short wizards with 3-5 steps on desktop.
Vertical
Sidebar navigation on left, content on right. Best for complex wizards with many steps or detailed descriptions.
Minimal
Simple "Step X of Y: Title" header. Best for embedding in dialogs or compact spaces.
Progress
Compact icon-only circles for space-constrained UIs. Best for mobile apps and narrow sidebars.
Breadcrumb
Badge-style indicators with chevron separators. Familiar navigation pattern similar to breadcrumb trails.
Breadcrumb navigation style:
Timeline
Traditional timeline with left border spine. Best for process documentation and historical step tracking.
Timeline layout:
Vertical Cards
Full-width clickable cards in vertical stack. Best for mobile-first designs and step selection interfaces.
Vertical cards layout:
<turbo-frame id="wizard-frame">
<%= rui_steps(
id: "setup-wizard",
url: wizard_path,
current_step: @current_step,
turbo_frame_id: frame_id,
variant: :horizontal,
navigation: :linear
) do |steps| %>
<% steps.with_step(title: "Account") do %>
<div class="py-6 space-y-4 max-w-md">
<%= form_with(local: true) do |f| %>
<%= f.rui_input(:email, label: "Email", required: true) %>
<% end %>
</div>
<% end %>
<% steps.with_step(title: "Profile") do %>
<div class="py-6 space-y-4 max-w-md">
<%= form_with(local: true) do |f| %>
<%= f.rui_input(:name, label: "Full Name", required: true) %>
<% end %>
</div>
<% end %>
<% end %>
</turbo-frame>
<turbo-frame id="wizard-frame">
<%= rui_steps(
id: "setup-wizard",
url: wizard_path,
current_step: @current_step,
turbo_frame_id: frame_id,
variant: :vertical,
navigation: :linear
) do |steps| %>
<% steps.with_step(title: "Account", description: "Create your account") do %>
<div class="py-6 space-y-4 max-w-md">
Account setup content...
</div>
<% end %>
<% steps.with_step(title: "Details", description: "Add your profile") do %>
<div class="py-6 space-y-4 max-w-md">
Profile details content...
</div>
<% end %>
<% end %>
</turbo-frame>
<turbo-frame id="wizard-frame">
<%= rui_steps(
id: "compact-wizard",
url: wizard_path,
current_step: @current_step,
turbo_frame_id: frame_id,
variant: :minimal,
navigation: :linear
) do |steps| %>
<% steps.with_step(title: "Step 1") { "Content for step 1..." } %>
<% steps.with_step(title: "Step 2") { "Content for step 2..." } %>
<% end %>
</turbo-frame>
<turbo-frame id="wizard-frame">
<%= rui_steps(
id: "progress-wizard",
url: wizard_path,
current_step: @current_step,
turbo_frame_id: frame_id,
variant: :progress,
navigation: :linear
) do |steps| %>
<% steps.with_step(title: "Account", icon: :user) { "..." } %>
<% steps.with_step(title: "Details", icon: :identification) { "..." } %>
<% steps.with_step(title: "Confirm", icon: :check) { "..." } %>
<% end %>
</turbo-frame>
Variant Selection Tips
- Desktop users with 3-5 steps: Use Horizontal
- Complex workflows with descriptions: Use Vertical or Timeline
- Mobile-first or narrow spaces: Use Progress or Vertical Cards
- Dialog wizards: Use Minimal
- Navigation-style UI: Use Breadcrumb
Step States
Steps automatically display their state based on the current position and any errors.
Completed
Green checkmark
Active
Primary with ring
Pending
Gray, muted
Error
Red indicator
<%# Show error state on a specific step %>
<%= rui_steps(
id: "wizard",
url: path,
current_step: @current_step,
error_step: @error_step # Set when server validation fails
) do |steps| %>
...
<% end %>
Layout Slots
Customize the wizard layout with header and footer slots for additional content, plus position controls for indicators and buttons.
Position Controls
Use these params to control where built-in elements appear:
indicators_position- Where step indicators appear::top(default),:bottom, or:nonebuttons_position- Where nav buttons appear::top,:bottom(default), or:none
| Slot | Method | Position | Use Case |
|---|---|---|---|
| Header | with_header |
Above everything | Title, description, cancel button |
| Footer | with_footer |
Below everything | Help links, terms, progress info |
Header Slot
Add custom content above the step indicators. Great for wizard title, description, or cancel buttons.
Account Setup
Complete these steps to activate your account
<%= rui_steps(id: "wizard", url: wizard_path) do |steps| %>
<% steps.with_header do %>
<div class="flex justify-between items-center pb-4 border-b border-zinc-200">
<div>
<h2 class="text-xl font-semibold">Account Setup</h2>
<p class="text-sm text-zinc-500">Complete these steps to activate your account</p>
</div>
<%= rui_button("Cancel", variant: :ghost, size: :sm) %>
</div>
<% end %>
<% steps.with_step("Account") { "..." } %>
<% steps.with_step("Profile") { "..." } %>
<% end %>
Footer Slot
Add custom content below the wizard. Great for help links, terms, or progress indicators.
<%= rui_steps(id: "wizard", url: wizard_path) do |steps| %>
<% steps.with_step("Account") { "..." } %>
<% steps.with_step("Profile") { "..." } %>
<% steps.with_footer do %>
<div class="flex justify-between items-center text-sm text-zinc-500">
<span>Your progress is saved automatically</span>
<a href="/help" class="text-blue-600 hover:underline">Need help?</a>
</div>
<% end %>
<% end %>
Swapping Positions
Use position params to swap where indicators and buttons appear.
Buttons at top, indicators at bottom:
<%= rui_steps(
id: "wizard",
url: wizard_path,
indicators_position: :bottom,
buttons_position: :top
) do |steps| %>
<% steps.with_step("Account") { "..." } %>
<% steps.with_step("Profile") { "..." } %>
<% end %>
Custom Navigation
Hide default buttons and add custom navigation in the header or footer.
<%= rui_steps(
id: "compact-wizard",
url: wizard_path,
buttons_position: :none <%# Hide default buttons %>
) do |steps| %>
<% steps.with_header do %>
<div class="flex justify-between items-center">
<span class="font-medium">Setup Progress</span>
<div class="flex gap-2">
<%= rui_button("Back", size: :sm, variant: :outline, data: { action: "rapid-rails-ui--steps#back" }) %>
<%= rui_button("Skip", size: :sm, variant: :ghost, data: { action: "rapid-rails-ui--steps#next" }) %>
<%= rui_button("Next", size: :sm, data: { action: "rapid-rails-ui--steps#next" }) %>
</div>
</div>
<% end %>
<% steps.with_step("Step 1") { "Content..." } %>
<% steps.with_step("Step 2") { "Content..." } %>
<% end %>
Stimulus Actions for Custom Buttons
| Action | Description |
|---|---|
rapid-rails-ui--steps#back |
Go to previous step |
rapid-rails-ui--steps#next |
Go to next step (validates first) |
rapid-rails-ui--steps#goToStep |
Jump to step (needs data-step) |
Both Slots Together
Use both slots together for complete layout control.
Welcome to Acme
Let's get you set up
<%= rui_steps(id: "onboarding", url: onboarding_path, color: :blue) do |steps| %>
<% steps.with_header do %>
<div class="flex justify-between items-center pb-4 border-b">
<div>
<h2 class="text-xl font-bold">Welcome to Acme</h2>
<p class="text-sm text-zinc-500">Let's get you set up</p>
</div>
<a href="/" class="text-zinc-400 hover:text-zinc-600">×</a>
</div>
<% end %>
<% steps.with_step("Account", icon: :user) do %>
<%= rui_input(type: :email, label: "Email", required: true) %>
<%= rui_input(type: :password, label: "Password", required: true) %>
<% end %>
<% steps.with_step("Profile", icon: :identification) do %>
<%= rui_input(label: "Full Name", required: true) %>
<% end %>
<% steps.with_step("Done", icon: :check_circle) do %>
<div class="text-center py-8">
<p class="text-green-600 font-medium">You're all set!</p>
</div>
<% end %>
<% steps.with_footer do %>
<div class="flex justify-between text-sm text-zinc-400 pt-4 border-t">
<span>Progress saved automatically</span>
<a href="/help" class="text-blue-500 hover:underline">Need help?</a>
</div>
<% end %>
<% end %>
Works With All Variants
Layout slots are supported across all 7 variants: horizontal, vertical, minimal, progress, breadcrumb, timeline, and vertical_cards.
Step Options
Configure individual steps with icons, descriptions, and optional markers.
With Icons
<%# With icons instead of numbers %>
<% steps.with_step(title: "Account", icon: :user) do %>
...
<% end %>
<%# With descriptions (shown in vertical variant) %>
<% steps.with_step(title: "Payment", description: "Credit card or PayPal") do %>
...
<% end %>
<%# Mark step as optional %>
<% steps.with_step(title: "Extras", optional: true) do %>
...
<% end %>
Controller Setup
Handle step transitions in your Rails controller. The component sends a 'direction' parameter.
class WizardsController < ApplicationController
before_action :set_wizard
def show
render partial: "step_#{@wizard.current_step}"
end
def update
case params[:direction]
when "next"
if @wizard.valid_for_step?(@wizard.current_step)
@wizard.increment!(:current_step)
else
flash.now[:alert] = "Please fix errors before continuing"
end
when "back"
@wizard.decrement!(:current_step)
when /^goto:(\d+)$/
target_step = $1.to_i
@wizard.update!(current_step: target_step) if can_navigate_to?(target_step)
end
render partial: "step_#{@wizard.current_step}"
end
private
def set_wizard
@wizard = Wizard.find_or_create_by(session_id: session.id)
end
def can_navigate_to?(step)
step <= @wizard.current_step + 1
end
end
LocalStorage Caching
The component caches the current step in LocalStorage for instant UI restore on page reload. Server state always takes precedence.
<%# Disable caching %>
<%= rui_steps(id: "wizard", url: path, cache_locally: false) do |steps| %>
...
<% end %>
<%# Storage key format: rui_steps_{id}_current %>
<%# Clear cache programmatically (in your Stimulus controller): %>
// this.stepsController.clearStorage()
Client-Side Validation
HTML5 validation runs before step transitions. Users can always go back without validation.
<%# Disable client-side validation %>
<%= rui_steps(id: "wizard", url: path, client_validation: false) do |steps| %>
...
<% end %>
<%# Validation behavior: %>
<%# - Next button: validates current step's inputs before proceeding %>
<%# - Back button: skips validation, allows going back with invalid data %>
<%# - Server validation still runs regardless of this setting %>
Real-World Examples
Post Creation Wizard
This is a fully functional wizard that creates a real Post. Fill in each step and click 'Create Post' to publish. Your data is saved as you navigate between steps.
<turbo-frame id="post-wizard-frame">
<% if @post.published? %>
<div class="py-6 space-y-4 text-center">
<%= rui_alert(type: :success, title: "Post Published!", dismissible: false) %>
</div>
<% else %>
<%= rui_steps(
id: "post-wizard",
url: posts_path,
current_step: @wizard_step || 0,
turbo_frame_id: frame_id,
variant: :horizontal,
navigation: :free,
submit_text: "Publish Post"
) do |steps| %>
<% steps.with_step(title: "Basic Info", icon: :file_text) do %>
<div class="py-6 space-y-4 max-w-lg">
<%= form_with(local: true) do |f| %>
<%= f.rui_input(:title,
label: "Title",
placeholder: "Enter post title",
value: @post.title,
required: true) %>
<%= f.rui_textarea(:excerpt,
label: "Excerpt",
placeholder: "Brief summary",
value: @post.excerpt,
rows: 3) %>
<%= f.rui_select(:status,
label: "Status",
collection: ["draft", "published", "archived"],
selected: @post.status) %>
<% end %>
</div>
<% end %>
<% steps.with_step(title: "Content", icon: :edit) do %>
<div class="py-6 space-y-4 max-w-lg">
<%= form_with(local: true) do |f| %>
<%= f.rui_textarea(:body,
label: "Body",
placeholder: "Write your post content...",
value: @post.body,
rows: 10) %>
<% end %>
</div>
<% end %>
<% steps.with_step(title: "Settings", icon: :settings) do %>
<div class="py-6 space-y-4 max-w-lg">
<%= form_with(local: true) do |f| %>
<%= f.rui_checkbox(:featured,
label: "Featured post",
checked: @post.featured?) %>
<%= f.rui_checkbox(:allow_comments,
label: "Allow comments",
checked: @post.allow_comments?) %>
<% end %>
</div>
<% end %>
<% steps.with_step(title: "Review", icon: :check_circle) do %>
<div class="py-6 max-w-lg">
<div class="bg-zinc-50 dark:bg-zinc-800 p-4 rounded-lg space-y-2 mb-4">
<p><strong>Title:</strong> <%= @post.title %></p>
<p><strong>Status:</strong> <%= @post.status %></p>
<p><strong>Featured:</strong> <%= @post.featured? ? "Yes" : "No" %></p>
</div>
<p class="text-zinc-600 dark:text-zinc-400">Click Publish to save your post.</p>
</div>
<% end %>
<% end %>
<% end %>
</turbo-frame>
User Onboarding Flow
StockLive Bidder Registration (6-Step Wizard)
A complex multi-step wizard for livestock auction bidder registration. Demonstrates handling of form data, combobox selections, and database persistence across 6 steps.
<turbo-frame id="stocklive-registration-frame">
<% if @registration.completed? %>
<div class="text-center py-8">
<%= rui_icon(:check, size: :lg, class: "inline text-green-600 mb-4") %>
<h3 class="text-2xl font-bold mb-2">Registration Complete!</h3>
<p class="text-zinc-600 dark:text-zinc-400 mb-6">Your bidder registration has been saved successfully.</p>
</div>
<% else %>
<%= rui_steps(
id: "stocklive-wizard",
url: stocklive_wizard_path,
current_step: @wizard_step || 0,
turbo_frame_id: frame_id,
variant: :horizontal,
navigation: :linear,
submit_text: "Complete Registration",
back_text: "Back",
next_text: "Continue"
) do |steps| %>
<% steps.with_step(title: "Sale", icon: :tag) do %>
<div class="py-6 space-y-4 max-w-lg">
<%= rui_text("Select Your Sale", size: :lg, weight: :semibold, class: "mb-4") %>
<%= form_with(local: true) do |f| %>
<%= f.rui_combobox(:sale_id, label: "Sale Event", collection: SALES_OPTIONS, required: true) %>
<%= f.rui_combobox(:account_type, label: "Account Type", collection: ACCOUNT_TYPES, required: true) %>
<% end %>
</div>
<% end %>
<% steps.with_step(title: "Personal", icon: :user) do %>
<div class="py-6 space-y-4 max-w-lg">
<%= rui_text("Personal Details", size: :lg, weight: :semibold, class: "mb-4") %>
<%= form_with(local: true) do |f| %>
<div class="grid grid-cols-2 gap-4">
<%= f.rui_input(:first_name, label: "First Name", required: true) %>
<%= f.rui_input(:last_name, label: "Last Name", required: true) %>
</div>
<%= f.rui_input(:email, label: "Email", type: :email, required: true) %>
<%= f.rui_input(:mobile, label: "Mobile", type: :tel, required: true) %>
<% end %>
</div>
<% end %>
<% steps.with_step(title: "Address", icon: :map_pin) do %>
<div class="py-6 space-y-4 max-w-lg">
<%= rui_text("Address Details", size: :lg, weight: :semibold, class: "mb-4") %>
<%= form_with(local: true) do |f| %>
<div class="grid grid-cols-2 gap-4">
<%= f.rui_select(:country, label: "Country", collection: COUNTRIES, required: true) %>
<%= f.rui_select(:state, label: "State", collection: STATES, required: true) %>
</div>
<%= f.rui_input(:street_address, label: "Street Address", required: true) %>
<div class="grid grid-cols-2 gap-4">
<%= f.rui_input(:suburb, label: "Suburb", required: true) %>
<%= f.rui_input(:postcode, label: "Postcode", required: true) %>
</div>
<% end %>
</div>
<% end %>
<% steps.with_step(title: "Business", icon: :briefcase) do %>
<div class="py-6 space-y-4 max-w-lg">
<%= rui_text("Business Details", size: :lg, weight: :semibold, class: "mb-4") %>
<%= form_with(local: true) do |f| %>
<%= f.rui_input(:trading_name, label: "Trading Name", required: true) %>
<div class="grid grid-cols-2 gap-4">
<%= f.rui_input(:pic, label: "PIC Number", required: true, maxlength: 8) %>
<%= f.rui_input(:abn, label: "ABN", required: true) %>
</div>
<% end %>
</div>
<% end %>
<% steps.with_step(title: "Agent", icon: :users) do %>
<div class="py-6 space-y-4 max-w-lg">
<%= rui_text("Agent Information", size: :lg, weight: :semibold, class: "mb-4") %>
<%= form_with(local: true) do |f| %>
<%= f.rui_input(:trading_agent, label: "Your Agent", required: true) %>
<div class="grid grid-cols-2 gap-4">
<%= f.rui_input(:selling_agent_name, label: "Agent Name", required: true) %>
<%= f.rui_input(:selling_agent_phone, label: "Agent Phone", type: :tel, required: true) %>
</div>
<% end %>
</div>
<% end %>
<% steps.with_step(title: "Review", icon: :check_circle) do %>
<div class="py-6">
<%= rui_text("Review Your Information", size: :lg, weight: :semibold, class: "mb-4") %>
<div class="bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg p-4 space-y-3 mb-6">
<div class="flex justify-between pb-3 border-b border-zinc-200 dark:border-zinc-700">
<span class="text-sm text-zinc-500">Name:</span>
<span class="font-medium"><%= [@registration.first_name, @registration.last_name].join(" ") %></span>
</div>
<div class="flex justify-between pb-3 border-b border-zinc-200 dark:border-zinc-700">
<span class="text-sm text-zinc-500">Email:</span>
<span class="font-medium"><%= @registration.email %></span>
</div>
<div class="flex justify-between">
<span class="text-sm text-zinc-500">Trading Name:</span>
<span class="font-medium"><%= @registration.trading_name %></span>
</div>
</div>
<p class="text-zinc-600 dark:text-zinc-400">Click 'Complete Registration' to save your bidder registration.</p>
</div>
<% end %>
<% end %>
<% end %>
</turbo-frame>
class PostsController < ApplicationController
def wizard
@post = Post.find_or_create_by(user_id: current_user.id, demo: true)
@wizard_step = session[:post_wizard_step] || 0
@wizard_total_steps = 4
case params[:direction]
when "next"
# Update post with current step data
if @post.update(post_params)
if @wizard_step >= @wizard_total_steps - 1
# Final step - publish and clear session
@post.update(published_at: Time.current)
session.delete(:post_wizard_step)
@completed = true
else
# Move to next step
@wizard_step = [@wizard_step + 1, @wizard_total_steps - 1].min
session[:post_wizard_step] = @wizard_step
end
end
when "back"
# Save current step, move to previous
@post.update(post_params) if post_params.present?
@wizard_step = [@wizard_step - 1, 0].max
session[:post_wizard_step] = @wizard_step
when /^goto:(\d+)$/
# Jump to specific step
@post.update(post_params) if post_params.present?
target = $1.to_i
@wizard_step = [[target, 0].max, @wizard_total_steps - 1].min
session[:post_wizard_step] = @wizard_step
end
# Render partial with turbo-frame response
render partial: "posts/wizard",
locals: {
post: @post,
wizard_step: @wizard_step,
completed: @completed || false
},
layout: false
end
private
def post_params
params.require(:post).permit(:title, :body, :excerpt, :status, :featured, :allow_comments)
end
end
Rails.application.routes.draw do
# POST to same endpoint handles wizard navigation
post "/posts/wizard", to: "posts#wizard", as: "posts_wizard"
resources :posts
end
Key Points
- Session tracking - Store current step in session
- Persistent updates - Save data on each step transition
- Direction parameter - Component sends
params[:direction](next, back, goto:N) - Partial rendering - Return HTML partial for Turbo frame update
- Frame targeting - Form targets outer turbo-frame for full component re-render
✓ Copy-Paste Ready Implementation Steps
-
Create a view partial (e.g.,
_wizard.html.erb)- Wrap
rui_stepscomponent in a<turbo-frame> - Pass
turbo_frame_idto the component (outer frame mode) - See examples above for complete implementations
- Wrap
-
Create controller action to handle wizard navigation
- Store
current_stepin session - Handle
params[:direction](next, back, goto:N) - Update model data on each step
- Render partial with
layout: falsefor Turbo
- Store
-
Add route that POSTs to your wizard action
post "/wizards/complete", to: "wizards#complete"- Component sends form submissions to this URL
-
Test in browser
- Navigate through steps - indicators update
- Fill form fields and go back - data persists
- No full page reloads (Turbo handles updates)
- Open browser console - no errors should appear
🎯 Quick Reference
- ✅ Indicators not updating? Make sure you're using outer frame mode (pass
turbo_frame_idparameter) - ✅ Form not submitting? Verify your route exists and accepts POST requests
- ✅ Data not persisting? Save form params to database in controller action
- ✅ Console errors? Check that Stimulus controller is loaded (included in gem automatically)
- ✅ Copy-paste example code? All code blocks on this page are production-ready
✅ OUTER FRAME MODE (Recommended for Wizards)
Use this when you need indicators to update on step transitions.
<turbo-frame id="wizard-frame">
<%= rui_steps(
id: "my-wizard",
url: wizard_path,
current_step: @current_step,
turbo_frame_id: frame_id # ← KEY: Pass outer frame ID
) do |steps| %>
<% steps.with_step(title: "Step 1") do %>
Step 1 content
<% end %>
<% end %>
</turbo-frame>
- Wrap component in turbo-frame in your partial
- Pass
turbo_frame_idto component - Form targets the OUTER frame (full component re-render)
- Stimulus controller gets new
current-value - ✅ Indicators update from gray to green
- ✅ Content updates
- ✅ All 7 variants work identically
INNER FRAME MODE (Default)
Used automatically if you don't pass turbo_frame_id.
<%= rui_steps(
id: "my-wizard",
url: wizard_path,
current_step: @current_step
# turbo_frame_id NOT passed = inner frame mode
) do |steps| %>
...
<% end %>
- Component creates internal turbo-frame automatically
- Form targets the INNER frame (content-only update)
- Indicators DON'T update (frame doesn't re-render component wrapper)
- Content updates only
- Use when: embedding in existing frames or simple demos
🎯 Quick Decision
Use Outer Frame Mode if:
- Building a real wizard form
- You want visual indicators (steps) to update
- You're in a partial or page view (not embedded)
Example: Post Wizard, StockLive Registration, Onboarding → All use Outer Frame Mode
Accessibility
The Steps component follows WAI-ARIA Wizard patterns for accessible multi-step forms.
ARIA Attributes
-
aria-label="Progress"on the navigation element -
role="list"on the step indicator list -
aria-current="step"marks the active step -
aria-disabled="true"on locked/unavailable steps -
aria-labelledbyon step content references step title
Keyboard Navigation
- Arrow Right/Down moves focus to next step indicator
- Arrow Left/Up moves focus to previous step indicator
- Home / End jumps to first/last step
- Enter or Space activates focused step (if allowed)
- Tab navigates between form elements within step content
Semantic HTML
-
Clickable step indicators use
<button>for proper interaction -
Non-clickable indicators (linear mode) use
<span>to indicate no interaction -
Step content wrapped in
role="group"with proper labeling -
Decorative SVG icons have
aria-hidden="true"
JavaScript API
The Steps component exposes Stimulus actions and custom events for programmatic control.
Stimulus Actions
Use these actions via data-action attributes:
| Action | Description |
|---|---|
steps#next |
Navigate to next step (validates if enabled) |
steps#back |
Navigate to previous step |
steps#goToStep |
Jump to specific step (respects navigation mode) |
steps#handleKeydown |
Keyboard navigation handler |
Public Methods
| Method | Description |
|---|---|
setError(stepIndex) |
Set error state on a specific step |
clearError(stepIndex) |
Clear error from a specific step |
clearAllErrors() |
Clear all error states |
clearStorage() |
Clear LocalStorage cache |
Custom Events
Listen to these events for lifecycle hooks:
| Event | When | Detail |
|---|---|---|
steps:before-navigate |
Before step transition | { from, to } |
steps:after-navigate |
After step transition | { from, to } |
steps:step-changed |
Step changed | { step } |
steps:validation-failed |
Client validation failed | { step, errors } |
steps:error-set |
Error state set | { step } |
steps:error-cleared |
Error state cleared | { step } |
<%# Listen for step changes %>
<div data-controller="my-controller"
data-action="steps:step-changed->my-controller#onStepChange
steps:validation-failed->my-controller#onValidationFailed">
<%= rui_steps(id: "wizard", url: path) do |steps| %>
...
<% end %>
</div>
// my_controller.js
onStepChange(event) {
const { step } = event.detail
console.log(`Now on step ${step}`)
}
onValidationFailed(event) {
const { step, errors } = event.detail
console.log(`Validation failed on step ${step}`)
}
// Get the steps controller
const stepsElement = document.querySelector('[data-controller*="steps"]')
const stepsController = application.getControllerForElementAndIdentifier(
stepsElement,
"rapid-rails-ui--steps"
)
// Set/clear errors
stepsController.setError(0) // Mark step 0 as having an error
stepsController.clearError(0) // Clear error from step 0
stepsController.clearAllErrors() // Clear all errors
// Clear cached state
stepsController.clearStorage()
CSS Architecture
Connector Line Classes
The Steps component renders connecting lines between step indicators. These lines use Tailwind utility classes that must be explicitly safelisted to appear in production CSS builds.
Horizontal connectors: h-px h-0.5 h-1
Vertical connectors: w-px w-0.5 w-1
Connector colors: bg-{color}-{200..600}
Dark mode colors: dark:bg-{color}-{600..800}
Safelist Configuration
These classes are registered in app/assets/tailwind/safelist.css
to ensure they appear in production CSS. If connector lines don't appear:
- Verify safelist includes connector classes
- Rebuild CSS:
bin/rails assets:clobber && bin/rails assets:precompile - Clear browser cache and reload
Tailwind Compilation Issue
Tailwind's content scanner sometimes misses dynamically-sized utilities like h-0.5.
The safelist explicitly tells Tailwind to generate these classes so they're always available, even if not found in source files.
API Reference
rui_steps
Multi-step wizard with Turbo Frame integration
| Parameter | Type | Default | Description |
|---|---|---|---|
| id* | String | — | Unique identifier for the wizard |
| url* | String | — | Form submission URL |
| current_step | Integer |
0
|
Current step index (0-based) |
| variant | Symbol |
:horizontal
|
Layout variant (:horizontal, :vertical, :minimal, :progress, :breadcrumb, :timeline, :vertical_cards) |
| navigation | Symbol |
:linear
|
Navigation mode (:linear, :free, :completed_only) |
| color | Symbol |
:zinc
|
Theme color for active indicators (:zinc, :primary, :blue, :green, :red, etc.) |
| size | Symbol |
:base
|
Size variant (:sm, :base, :lg) |
| indicators_position | Symbol |
:top
|
Position of step indicators (:top, :bottom, :none) |
| buttons_position | Symbol |
:bottom
|
Position of navigation buttons (:top, :bottom, :none) |
| cache_locally | Boolean |
true
|
Cache current step in LocalStorage |
| client_validation | Boolean |
true
|
Run HTML5 validation before step transitions |
| error_step | Integer | — | Step index with server-side validation error |
| back_text | String |
"Back"
|
Back button label |
| next_text | String |
"Next"
|
Next button label |
| submit_text | String |
"Submit"
|
Submit button label (shown on last step) |
| loading_text | String |
"Saving..."
|
Button text during Turbo submission |
| class | String | — | Custom CSS classes |
Slots
Content slots for customizing component parts
| Slot | Description |
|---|---|
| step | Define individual wizard steps using with_step(). Title can be positional or keyword: with_step('Title') or with_step(title: 'Title'). |
| header | Custom content rendered above step indicators. Great for wizard title, description, or cancel button. |
| footer | Custom content rendered below everything. Great for help links, terms, or progress info. |