Documentation

Comprehensive guides and references!

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

  1. Overview
  2. Quick Start
  3. Implementation Guide
  4. Modal Configuration
  5. Form Integration
  6. Stimulus Controller Features
  7. Accessibility & UX
  8. Troubleshooting
  9. 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

๐Ÿ”— 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

๐Ÿ”— 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 %>

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 %>

๐Ÿ› ๏ธ 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.

๐Ÿ”„ 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 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.

  1. Verify Turbo Frame Target: Ensure data: { turbo_frame: "modal" }
  2. Check Controller Response: Must respond to turbo_stream format
  3. Validate Modal Partial: Ensure shared/turbo_modal exists
  4. JavaScript Console: Check for Stimulus controller errors

๐Ÿ”’ 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.

  1. Verify Controller Connection: Check data-controller="modal"
  2. Check Event Bindings: Ensure data-action attributes are correct
  3. Inspect Close Buttons: Verify data-action="click->modal#closeModal"
  4. 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.

Last updated: April 13, 2026

Was this helpful?

On this page