Turbo Modal System Usage Guide
Complete guide for implementing accessible, responsive modals using Hotwire Turbo, Stimulus.js, and Tailwind CSS 4.0.
Table of Contents
- Overview
- Quick Start
- Implementation Guide
- Modal Configuration
- Form Integration
- Stimulus Controller Features
- Accessibility & UX
- Troubleshooting
- Advanced Usage
Overview
🚀 Modern Modal System
A sophisticated modal implementation leveraging the power of Hotwire Turbo with comprehensive accessibility and modern UX patterns.
Core Features
| Feature | Description | Benefit |
|---|---|---|
| Keyboard Navigation | ESC to close, Tab trapping | Full accessibility support |
| Focus Management | Automatic focus and restoration | Screen reader friendly |
| Dark Mode Support | Complete dark theme integration | Modern UI standards |
| Responsive Design | Mobile-optimized layouts | Cross-device compatibility |
| Smooth Animations | CSS transition animations | Professional UX |
| WCAG 2.2 AA Compliant | Full accessibility compliance | Inclusive design |
Technology Stack
⚡ Modern Architecture
Built on cutting-edge web technologies for optimal performance and developer experience.
- 🏗️ Hotwire Turbo - Server-side rendering with SPA-like experience
- ⚡ Stimulus.js - JavaScript behaviors and interactions
- 🎨 Tailwind CSS 4.0 - Utility-first styling system
- ♿ Accessibility First - WCAG 2.2 AA compliant implementation
Quick Start
⚡ Get Started in Minutes
Follow these essential steps to implement your first Turbo modal.
Essential Components
1. Controller Action Setup
🎮 Server Response Configuration
Configure your Rails controller to respond with modal content using Turbo Streams.
def edit
respond_to do |format|
format.html # Regular edit page
format.turbo_stream do
render turbo_stream: turbo_stream.replace(:modal,
partial: "shared/turbo_modal",
locals: {
modal_title: "Edit #{@organization.name}",
modal_size: "lg"
}) do
render partial: "edit_form", locals: { organization: @organization }
end
end
end
end
2. Link Configuration
🔗 Trigger Setup
Configure links to properly target the modal frame for seamless integration.
<%= link_to "Edit", edit_organization_path(organization),
data: {
turbo_frame: "modal",
turbo_method: :get
} %>
3. Form Integration
📝 Form Configuration
Set up forms to work properly within the modal context.
<%= form_with(model: organization,
local: false,
data: { turbo_frame: "_top" }) do |form| %>
<!-- form fields -->
<% end %>
Implementation Guide
Step 1: Controller Setup
🎯 Turbo Stream Integration
Configure your controllers to respond with modal content using Turbo Streams.
Basic Modal Response Pattern
🔧 Standard Implementation
The fundamental pattern for rendering modal content via Turbo Streams.
class OrganizationsController < ApplicationController
def edit
respond_to do |format|
format.html # Fallback for direct access
format.turbo_stream do
render turbo_stream: turbo_stream.replace(:modal,
partial: "shared/turbo_modal",
locals: modal_locals
) do
render partial: "organizations/edit_form",
locals: { organization: @organization }
end
end
end
end
private
def modal_locals
{
modal_title: "Edit #{@organization.name}",
modal_size: "lg",
modal_id: "edit-organization-modal"
}
end
end
Advanced Response Handling
⚡ Multi-Stream Responses
Advanced pattern for updating multiple page elements simultaneously.
def show
respond_to do |format|
format.html
format.turbo_stream do
render turbo_stream: [
turbo_stream.replace(:modal,
partial: "shared/turbo_modal",
locals: {
modal_title: @organization.name,
modal_size: "xl"
}
) do
render partial: "organizations/details",
locals: { organization: @organization }
end,
turbo_stream.append(:page_history,
partial: "shared/history_entry",
locals: { action: "viewed", resource: @organization }
)
]
end
end
end
Step 2: Link and Button Setup
🔗 Trigger Configuration
Configure links and buttons to properly target the modal frame.
Basic Modal Triggers
🎯 Standard Trigger Patterns
Common patterns for triggering modal display from various UI elements.
<!-- Edit button -->
<%= link_to "Edit Organization",
edit_organization_path(organization),
class: "btn btn-primary",
data: {
turbo_frame: "modal",
turbo_method: :get
} %>
<!-- Delete confirmation modal -->
<%= link_to "Delete",
organization_path(organization),
class: "btn btn-danger",
data: {
turbo_frame: "modal",
turbo_method: :delete,
turbo_confirm: "Are you sure?"
} %>
Button with Loading States
🔄 Enhanced UX Patterns
Implement loading states for better user feedback during modal loading.
<%= link_to edit_organization_path(organization),
class: "btn btn-primary",
data: {
turbo_frame: "modal",
turbo_method: :get,
action: "click->loading#start"
} do %>
<svg class="size-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
Edit Organization
<% end %>
Modal Configuration
Available Modal Sizes
📐 Responsive Sizing Options
Choose the appropriate modal size based on your content and use case.
| Size Parameter | CSS Classes | Viewport Width | Best Use Case |
|---|---|---|---|
sm or small |
max-w-sm |
~384px | Confirmations, alerts |
md (default) |
max-w-md |
~448px | Simple forms |
lg or large |
max-w-2xl |
~672px | Complex forms |
xl or extra-large |
max-w-4xl |
~896px | Data tables, dashboards |
full |
max-w-7xl |
~1280px | Full-screen experiences |
Size Configuration Examples
🎨 Size Implementation
Practical examples of different modal sizes for various use cases.
<!-- Small confirmation modal -->
<%= render 'shared/turbo_modal',
modal_title: "Confirm Action",
modal_size: "sm" do %>
<p>Are you sure you want to proceed?</p>
<% end %>
<!-- Large form modal -->
<%= render 'shared/turbo_modal',
modal_title: "Edit Organization",
modal_size: "lg" do %>
<%= render 'organizations/edit_form' %>
<% end %>
<!-- Extra-large data modal -->
<%= render 'shared/turbo_modal',
modal_title: "Analytics Dashboard",
modal_size: "xl" do %>
<%= render 'analytics/dashboard' %>
<% end %>
Modal Helper Configuration
🛠️ Helper Method Integration
Utilize the modal size helper for consistent styling across your application.
# app/helpers/application_helper.rb
def modal_size_classes(size = nil)
case size&.to_s
when 'xs', 'extra-small'
'max-w-xs'
when 'sm', 'small'
'max-w-sm'
when 'lg', 'large'
'max-w-2xl'
when 'xl', 'extra-large'
'max-w-4xl'
when '2xl', 'extra-extra-large'
'max-w-6xl'
when 'full'
'max-w-7xl'
else
'max-w-md' # default medium size
end
end
Form Integration
Form Configuration for Modals
📝 Proper Form Setup
Configure forms to work seamlessly with the modal system and Turbo.
Standard Form Pattern
🎯 Complete Form Implementation
Comprehensive form pattern optimized for modal usage with proper styling and accessibility.
<%= form_with(model: [@organization, @project],
local: false,
data: { turbo_frame: "_top" },
class: "space-y-4") do |form| %>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="sm:col-span-2">
<%= form.label :name, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<%= form.text_field :name,
class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:text-white" %>
</div>
<div>
<%= form.label :status, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<%= form.select :status,
options_for_select([['Active', 'active'], ['Inactive', 'inactive']]),
{ prompt: 'Select status' },
{ class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:text-white" } %>
</div>
<div>
<%= form.label :priority, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<%= form.number_field :priority,
in: 1..5,
class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:text-white" %>
</div>
</div>
<div class="flex justify-end space-x-2 pt-4 border-t border-gray-200 dark:border-gray-700">
<%= link_to "Cancel", "#",
class: "btn btn-secondary",
data: { action: "click->modal#closeModal" } %>
<%= form.submit "Save Changes",
class: "btn btn-primary" %>
</div>
<% end %>
Success Response Handling
✅ Post-Form Processing
Handle successful form submissions with appropriate redirects or updates.
Option A: Redirect to Show Page (Recommended)
🔄 Clean Redirect Pattern
The recommended approach for handling successful form submissions.
def update
if @organization.update(organization_params)
respond_to do |format|
format.turbo_stream do
redirect_to organization_path(@organization),
notice: "Organization was successfully updated."
end
end
else
handle_form_errors
end
end
Option B: Update in Place with Turbo Streams
⚡ In-Place Updates
Advanced pattern for updating content without full page reload.
def update
if @organization.update(organization_params)
respond_to do |format|
format.turbo_stream do
flash.now[:notice] = "Organization was successfully updated."
render turbo_stream: [
turbo_stream.remove(:modal),
turbo_stream.replace("organization_#{@organization.id}",
partial: "organization",
locals: { organization: @organization }),
turbo_stream.prepend("flash",
partial: "shared/flash")
]
end
end
else
handle_form_errors
end
end
Error Handling
🚨 Validation Error Management
Gracefully handle validation errors within the modal context.
def handle_form_errors
respond_to do |format|
format.turbo_stream do
render turbo_stream: turbo_stream.replace(:inside_modal,
partial: "edit_form",
locals: { organization: @organization }
), status: :unprocessable_entity
end
end
end
# Alternative: Update specific form sections
def handle_field_errors
respond_to do |format|
format.turbo_stream do
render turbo_stream: [
turbo_stream.replace("form_errors",
partial: "shared/form_errors",
locals: { object: @organization }),
turbo_stream.replace("form_fields",
partial: "organizations/form_fields",
locals: { form: form, organization: @organization })
], status: :unprocessable_entity
end
end
end
Stimulus Controller Features
Automatic Functionality
🤖 Built-in Behaviors
The modal Stimulus controller provides comprehensive functionality out of the box.
| Feature | Trigger | Description |
|---|---|---|
| Focus Management | Modal open | Automatically focuses first focusable element |
| Focus Trapping | Tab navigation | Keeps navigation within modal boundaries |
| Keyboard Navigation | ESC key | Closes modal instantly |
| Backdrop Close | Click outside | Closes modal when clicking backdrop |
| Body Scroll Lock | Modal open | Prevents background scrolling |
| Smooth Animations | Open/close | Scale and opacity transitions |
Controller Methods and Events
JavaScript Integration
⚡ Advanced Integration
Custom JavaScript patterns for complex modal interactions.
// Access modal controller from other Stimulus controllers
connect() {
this.modalElement = document.getElementById('modal')
this.modalController = this.application.getControllerForElementAndIdentifier(
this.modalElement,
"modal"
)
}
// Manually close modal
closeModal() {
if (this.modalController) {
this.modalController.closeModal()
}
}
// Check if modal is open
isModalOpen() {
return this.modalElement && !this.modalElement.classList.contains("hidden")
}
// Listen for modal events
modalOpened() {
// Custom logic when modal opens
console.log("Modal opened")
}
modalClosed() {
// Custom logic when modal closes
console.log("Modal closed")
}
Custom Event Handling
📡 Event System Integration
Listen for and respond to modal lifecycle events.
<!-- Listen for modal events -->
<div data-controller="modal-listener"
data-action="modal:opened->modal-listener#handleModalOpened
modal:closed->modal-listener#handleModalClosed">
<!-- content -->
</div>
Accessibility & UX
Accessibility Features
♿ WCAG 2.2 AA Compliance
Comprehensive accessibility support built into every modal component.
| Feature | Implementation | Benefit |
|---|---|---|
| Focus Management | Automatic focus on first element | Screen reader navigation |
| Focus Trapping | Tab/Shift+Tab cycling | Keyboard-only users |
| Keyboard Navigation | ESC key closing | Universal accessibility |
| ARIA Attributes | role="dialog", aria-modal="true" |
Screen reader context |
| Semantic HTML | Proper heading hierarchy | Document structure |
| High Contrast | Dark mode support | Visual accessibility |
ARIA Implementation
🎯 Accessibility Standards
Complete ARIA implementation ensuring proper screen reader support.
<!-- Modal with complete ARIA support -->
<div id="modal"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
aria-describedby="modal-description"
data-controller="modal"
data-action="keydown.esc->modal#closeModal click->modal#backdropClick"
class="fixed inset-0 z-50 overflow-y-auto"
style="display: none;">
<div class="flex min-h-screen items-center justify-center p-4">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full">
<header class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 id="modal-title" class="text-lg font-semibold text-gray-900 dark:text-white">
<%= modal_title %>
</h2>
</header>
<div id="modal-description" class="sr-only">
Modal dialog for <%= modal_title %>
</div>
<div class="p-6">
<%= yield %>
</div>
</div>
</div>
</div>
UX Best Practices
Loading States
🔄 User Feedback Patterns
Provide clear feedback during loading and processing states.
<!-- Button with loading state -->
<%= link_to edit_path(resource),
data: { turbo_frame: "modal" },
class: "btn btn-primary" do %>
<span data-loading-text="Loading...">Edit</span>
<svg class="animate-spin size-4 ml-2 hidden" data-loading-spinner>
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"/>
<path fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"/>
</svg>
<% end %>
Progressive Enhancement
🌟 Graceful Degradation
Ensure functionality works for all users, regardless of JavaScript support.
<!-- Fallback for non-JavaScript users -->
<noscript>
<%= link_to "Edit (opens in new page)", edit_path(resource),
class: "btn btn-primary" %>
</noscript>
<!-- Enhanced version for JavaScript users -->
<div class="js-only">
<%= link_to "Edit", edit_path(resource),
data: { turbo_frame: "modal" },
class: "btn btn-primary" %>
</div>
Troubleshooting
Common Issues & Solutions
Modal Not Opening
🚨 Modal Display Issues
Diagnostic steps and solutions for modal display problems.
Symptoms:
- Link clicks don't show modal
- Page refreshes instead of modal
- JavaScript errors in console
Diagnostic Steps:
🔍 Debugging Process
Systematic approach to identifying modal display issues.
<!-- Check Turbo Frame target -->
<%= link_to "Test", test_path,
data: {
turbo_frame: "modal",
turbo_method: :get
} %>
<!-- Verify controller response -->
def test
respond_to do |format|
format.turbo_stream do
render turbo_stream: turbo_stream.replace(:modal, "Modal content")
end
end
end
Solutions:
✅ Resolution Steps
Step-by-step solutions for common modal opening issues.
- Verify Turbo Frame Target: Ensure
data: { turbo_frame: "modal" } - Check Controller Response: Must respond to
turbo_streamformat - Validate Modal Partial: Ensure
shared/turbo_modalexists - JavaScript Console: Check for Stimulus controller errors
Modal Not Closing
🔒 Modal Dismissal Problems
Troubleshooting modal closing functionality.
Symptoms:
- ESC key doesn't work
- Backdrop clicks don't close modal
- Close button non-functional
Diagnostic Steps:
🧪 Testing Approach
Methods to verify modal closing functionality.
// Check modal controller connection
console.log(document.querySelector('[data-controller="modal"]'))
// Verify event listeners
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
console.log('ESC key detected')
}
})
Solutions:
🔧 Fix Implementation
Common solutions for modal closing issues.
- Verify Controller Connection: Check
data-controller="modal" - Check Event Bindings: Ensure
data-actionattributes are correct - Inspect Close Buttons: Verify
data-action="click->modal#closeModal" - JavaScript Errors: Check console for Stimulus errors
Focus Management Issues
🎯 Focus and Accessibility Problems
Resolving focus management and accessibility concerns.
Symptoms:
- No automatic focus on modal open
- Tab navigation escapes modal
- Screen reader issues
Solutions:
♿ Accessibility Fixes
Ensuring proper focus management for all users.
<!-- Ensure focusable elements exist -->
<div class="modal-content">
<button type="button" class="sr-only" data-modal-target="firstFocusable">
First focusable element
</button>
<!-- Your modal content -->
<button type="button" class="sr-only" data-modal-target="lastFocusable">
Last focusable element
</button>
</div>
Styling and Animation Issues
🎨 Visual and Animation Problems
Resolving styling and animation-related issues.
Common Solutions:
🎬 Animation Fixes
Ensuring smooth animations and proper styling.
<!-- Ensure proper CSS classes -->
<div class="fixed inset-0 z-50 overflow-y-auto transition-opacity duration-300"
data-controller="modal"
data-modal-backdrop-classes="bg-gray-500 bg-opacity-50"
data-modal-container-classes="flex min-h-screen items-center justify-center p-4">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl transform transition-all duration-300 scale-95 opacity-0"
data-modal-target="container">
<!-- Modal content -->
</div>
</div>
Advanced Usage
Custom Modal Sizes
📏 Extended Size Options
Add custom modal sizes for specific use cases.
# app/helpers/application_helper.rb
def modal_size_classes(size = nil)
case size&.to_s
when 'xs', 'extra-small'
'max-w-xs'
when 'sm', 'small'
'max-w-sm'
when 'md', 'medium'
'max-w-md'
when 'lg', 'large'
'max-w-2xl'
when 'xl', 'extra-large'
'max-w-4xl'
when '2xl', 'extra-extra-large'
'max-w-6xl'
when '3xl', 'triple-large'
'max-w-7xl'
when 'full', 'fullscreen'
'max-w-none w-full h-full'
when 'custom-narrow'
'max-w-96'
when 'custom-wide'
'max-w-5xl'
else
'max-w-md'
end
end
Custom Modal Actions
🔧 Advanced Modal Functionality
Create modals with custom footer actions and behaviors.
<%= render 'shared/turbo_modal',
modal_title: "Confirm Dangerous Action",
modal_size: "sm" do %>
<div class="text-center p-6">
<div class="mx-auto flex items-center justify-center size-12 rounded-full bg-red-100 dark:bg-red-900 mb-4">
<svg class="size-6 text-red-600 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"/>
</svg>
</div>
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">
Are you absolutely sure?
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
This action cannot be undone. This will permanently delete the organization and all associated data.
</p>
<div class="flex space-x-3 justify-center">
<button type="button"
class="btn btn-secondary"
data-action="click->modal#closeModal">
Cancel
</button>
<%= link_to "Yes, delete organization",
organization_path(@organization),
method: :delete,
class: "btn btn-danger",
data: {
turbo_frame: "_top",
turbo_confirm: false
} %>
</div>
</div>
<% end %>
Multi-Step Modal Workflows
🔄 Complex Modal Interactions
Implement multi-step processes within modal contexts.
# Controller for multi-step modal
class Organizations::SetupController < ApplicationController
def step_1
respond_to do |format|
format.turbo_stream do
render turbo_stream: turbo_stream.replace(:modal,
partial: "shared/turbo_modal",
locals: {
modal_title: "Organization Setup - Step 1 of 3",
modal_size: "lg"
}
) do
render partial: "organizations/setup/step_1"
end
end
end
end
def step_2
if valid_step_1?
render turbo_stream: turbo_stream.replace(:inside_modal,
partial: "organizations/setup/step_2")
else
render turbo_stream: turbo_stream.replace(:inside_modal,
partial: "organizations/setup/step_1"),
status: :unprocessable_entity
end
end
def complete
if @organization.update(setup_params)
redirect_to organization_path(@organization),
notice: "Organization setup completed!"
else
render turbo_stream: turbo_stream.replace(:inside_modal,
partial: "organizations/setup/step_3"),
status: :unprocessable_entity
end
end
end
Summary
The Turbo Modal System provides:
🌟 Complete Modal Solution
A comprehensive, accessible, and performant modal system that enhances user experience while maintaining modern development standards.
- 🚀 Modern Architecture - Hotwire Turbo with Stimulus.js integration
- ♿ Accessibility First - WCAG 2.2 AA compliant implementation
- 🎨 Beautiful Design - Tailwind CSS 4.0 with dark mode support
- 📱 Responsive Experience - Mobile-optimized across all devices
- ⚡ High Performance - Efficient server-side rendering
- 🛡️ Robust Error Handling - Comprehensive form and validation support
- 🔧 Highly Customizable - Flexible sizing and styling options
🎉 Modal Mastery Achieved!
You now have a complete understanding of implementing professional, accessible modals that enhance user experience while maintaining modern development standards. The system handles complex workflows while remaining simple to implement and maintain.