dialog-patterns
Native Dialog Patterns for Rails
Build accessible, modern dialog UIs using the native HTML <dialog> element with Turbo Frames and Stimulus. No JavaScript frameworks or heavy libraries required.
When to Use This Skill
- Building modal dialogs for forms, confirmations, or content
- Creating toast/alert notifications
- Implementing confirmation dialogs (delete, destructive actions)
- Any overlay UI that needs focus management and accessibility
Why Native <dialog>?
| Feature | Native <dialog> |
Custom Modal |
|---|---|---|
| Focus trapping | Built-in | Manual implementation |
| ESC to close | Built-in | Manual implementation |
| Backdrop | Built-in (::backdrop) |
Manual overlay |
| Accessibility | Native role="dialog" |
Manual ARIA |
| Top layer | Automatic (above all content) | z-index battles |
| Scroll lock | Automatic | Manual overflow: hidden |
Zero-JavaScript Confirmation Dialogs (Recommended)
Modern browsers support the Invoker Commands API for declarative dialog control—no JavaScript required. See references/zero-js-patterns.md for complete examples.
Quick Reference
<%= button_tag "Delete", commandfor: "delete-#{post.id}", command: "show-modal" %>
<dialog id="delete-<%= post.id %>" closedby="any" role="alertdialog">
<h3>Delete "<%= post.title %>"?</h3>
<button commandfor="delete-<%= post.id %>" command="close">Cancel</button>
<%= button_to "Delete", post, method: :delete %>
</dialog>
Key Attributes
| Attribute | Purpose |
|---|---|
commandfor="id" |
References the dialog to control |
command="show-modal" |
Opens as modal (backdrop, focus trap) |
command="close" |
Closes the dialog |
closedby="any" |
Enables backdrop click and ESC to close |
When to Use Zero-JS vs Stimulus
| Scenario | Approach |
|---|---|
| Simple confirmations | Zero-JS (Invoker Commands) |
| Modals with async content | Stimulus + Turbo Frames |
| Complex multi-step dialogs | Stimulus controller |
| Animations | CSS @starting-style |
Additional Patterns (see references/)
- CSS animations with
@starting-stylefor enter/exit transitions - Turbo.config.forms.confirm to replace ugly browser dialogs
- Progressive enhancement for cross-browser compatibility
Core Pattern: Async Modal with Turbo Frames
The recommended pattern for Rails modals combines three technologies:
- Turbo Frame - Async content loading without page reload
- Native
<dialog>- Accessible modal presentation - Stimulus controller - Lifecycle management
Step 1: Layout Container
Add a modal turbo-frame to your layout:
<%# app/views/layouts/application.html.erb %>
<body>
<%= yield %>
<%# Modal injection point %>
<%= turbo_frame_tag :modal %>
</body>
Step 2: Trigger Links
Target the modal frame from any link:
<%# Any view %>
<%= link_to "New Post", new_post_path, data: { turbo_frame: :modal } %>
<%= link_to "Edit", edit_post_path(@post), data: { turbo_frame: :modal } %>
<%= link_to "Confirm Delete", confirm_delete_post_path(@post), data: { turbo_frame: :modal } %>
Step 3: Modal Content View
Wrap modal content in matching turbo-frame with nested inner frame:
<%# app/views/posts/new.html.erb %>
<%= turbo_frame_tag :modal do %>
<%# Inner frame prevents flash during form validation %>
<%= turbo_frame_tag :modal_content do %>
<dialog data-controller="dialog" data-action="click->dialog#clickOutside" open>
<article>
<header>
<h2>New Post</h2>
<button data-action="dialog#close" aria-label="Close">×</button>
</header>
<%= render "form", post: @post %>
</article>
</dialog>
<% end %>
<% end %>
Step 4: Stimulus Controller
Key behaviors: showModal() on connect, replaceChildren() on disconnect (prevents stale content), clickOutside for backdrop close.
See references/dialog-examples.md for full Stimulus controller, CSS styling, and Tailwind variant.
Why Nested Turbo Frames?
The nested frame pattern (modal > modal_content) prevents content flashing:
<%= turbo_frame_tag :modal do %>
<%= turbo_frame_tag :modal_content do %>
<dialog>...</dialog>
<% end %>
<% end %>
Problem without nested frame: When a form inside the modal has validation errors and re-renders, the outer frame briefly shows the old content before replacing it.
Solution with nested frame: The inner frame handles form re-renders independently, keeping the modal structure stable.
Form Handling in Modals
Successful Submission
Redirect with Turbo to close modal and update page:
# app/controllers/posts_controller.rb
def create
@post = Post.new(post_params)
if @post.save
redirect_to posts_path, notice: "Post created!"
else
render :new, status: :unprocessable_entity
end
end
The redirect navigates _top (full page), effectively closing the modal.
Validation Errors
Re-render the form with 422 status to keep modal open:
render :new, status: :unprocessable_entity
Turbo Stream Response (Stay in Modal)
Use turbo_stream.update("modal", "") to clear modal without full redirect. See references/dialog-examples.md for full example.
Confirmation Dialog Pattern
For destructive actions: add a confirm_delete member route, render a dialog in a turbo frame, trigger via link_to with data: { turbo_frame: :modal }.
See references/dialog-examples.md for full confirmation dialog view, route, and trigger.
Alert/Toast Pattern
For flash messages and notifications. Use show() instead of showModal() for non-modal presentation. See references/toast-slideover-patterns.md for complete implementation.
<dialog class="toast" data-controller="toast" data-toast-duration-value="5000">
<p><%= message %></p>
</dialog>
Key difference: show() opens without backdrop or focus trap (toasts), showModal() centers with backdrop (modals).
Slideover Panel Pattern
For side panels (settings, filters, details). See references/toast-slideover-patterns.md for styling and animations.
<dialog class="slideover" data-controller="dialog" data-action="click->dialog#clickOutside">
<aside>
<header><h2>Filters</h2></header>
<%= render "filters" %>
</aside>
</dialog>
Accessibility
Native <dialog> provides focus trapping, ESC close, background inert, and top layer automatically. Additionally ensure:
- Visible close button (not just ESC)
aria-labelledby/aria-describedbyfor descriptive context- Focus return to trigger element on close (store
document.activeElementinconnect())
See references/dialog-examples.md for enhanced accessibility and focus return examples.
Common Patterns Summary
| Pattern | Container | Stimulus | show method |
|---|---|---|---|
| Modal form | turbo_frame_tag :modal |
dialog |
showModal() |
| Confirmation | turbo_frame_tag :modal |
dialog |
showModal() |
| Toast/Alert | Fixed position | toast |
show() |
| Slideover | turbo_frame_tag :modal |
dialog |
showModal() |
Anti-Patterns to Avoid
| Anti-Pattern | Problem | Solution |
|---|---|---|
Custom modal without <dialog> |
No native accessibility | Use native <dialog> |
| Missing nested turbo-frame | Content flash on validation | Add inner frame |
| Not clearing frame on close | Stale content on reopen | Clear with replaceChildren() in disconnect() |
| z-index for stacking | Battles with other elements | <dialog> uses top layer |
| Manual focus trap | Complex, error-prone | showModal() handles it |
| Inline backdrop div | Extra markup | Use ::backdrop pseudo-element |
Testing Dialogs
# System test - use `within "dialog"` to scope assertions
within "dialog" do
fill_in "Title", with: "My Post"
click_button "Create"
end
expect(page).not_to have_selector("dialog[open]") # Modal closed
Browser Support
| Pattern | Chrome | Firefox | Safari |
|---|---|---|---|
Native <dialog> |
37+ | 98+ | 15.4+ |
| Invoker Commands | 135+ | 144+ | 26.2+ |
@starting-style |
117+ | 129+ | 17.5+ |
For older browsers: dialog polyfill, invokers polyfill. See references/zero-js-patterns.md for progressive enhancement strategies.