The Ultimate Turbo Modal for Rails (UTMR)
There are MANY Turbo/Hotwire/Stimulus modal dialog implementations out there. However, as you may have learned, the majority fall short in different, often subtle ways. They generally cover the basics quite well, but do not check all the boxes for real-world use.
UTMR aims to be the be-all and end-all of Turbo Modals. I believe it is the best (only?) full-featured implementation and checks all the boxes. It is feature-rich, yet extremely easy to use. Its purpose is to make it as easy as possible to have polished Turbo-backed modals and drawers.
Under the hood, it uses Stimulus, Turbo, the native HTML <dialog> element, and Idiomorph.
It ships in two flavors: Tailwind (v4+) and vanilla CSS. It is easy to create your own flavor to suit your needs.
Screenshots & Demo Video
| | |
|:-------------------------:|:-------------------------:|
|
|
|
|
|
|
| | |
Installation
$ bundle add ultimate_turbo_modal
$ bundle exec rails g ultimate_turbo_modal:install
Stylesheet
The tailwind and custom flavors are styled entirely by the classes in their flavor
file, so they need no stylesheet.
The vanilla flavor ships its styles as a CSS file. The install generator adds it to
app/views/layouts/application.html.erb for you; if you need to add it by hand, put this
in your layout's <head>:
<%= stylesheet_link_tag "ultimate_turbo_modal", "data-turbo-track": "reload" %>
The gem puts the stylesheet on the asset pipeline's load path, so this works with Propshaft and Sprockets whether you use importmaps or a JavaScript bundler. Keep it before your own stylesheets: where the two collide at equal specificity the later one wins, so loading the defaults first is what lets your own styles override them.
If you would rather pull the CSS through your bundler, the npm package also ships it:
@import 'ultimate_turbo_modal/dist/vanilla.css';
Usage
- Wrap your view inside a
modalblock as follow:
<%= modal do %>
Hello World!
<% end %>
- Link to your view by specifying
modalas the target Turbo Frame:
<%= link_to "Open Modal", "/hello_world", data: { turbo_frame: "modal" } %>
Clicking on the link will automatically open the content of the view inside a modal. If you open the link in a new tab, it will render normally outside of the modal. Nothing to do!
This is really all you should need to do for most use cases.
Please note: The generator automatically adds <turbo-frame id="modal"></turbo-frame> to your application layout. If you need to open modals or drawers in another layout, please add this HTML snippet manually.
Setting Title and Footer
You can set a custom title and footer by passing a block. For example:
<%= modal do |m| %>
<% m.title do %>
<div>My Title</div>
<% end %>
<p>Your modal body</p>
<%= form_with url: "#", html: { id: "myform" } do |f| %>
<p>..</p>
<% end %>
<% m.footer do %>
<input type="submit" form="myform">Submit</input>
<% end %>
<% end %>
You can also set a title with options (see below).
Detecting modal at render time
If you need to do something a little bit more advanced when the view is shown outside of a modal, you can use the #inside_modal? method as such:
<% if inside_modal? %>
<h1 class="text-2xl mb-8">Hello from modal</h1>
<% else %>
<h1 class="text-2xl mb-8">Hello from a normal page render</h1>
<% end %>
Options
Do not get overwhelmed with all the options. The defaults are sensible. You can change the defaults with an initializer:
# config/initializers/ultimate_turbo_modal.rb
UltimateTurboModal.configure do |config|
config.flavor = :tailwind
config.allowed_click_outside_selector = []
config.modal do |m|
m.advance = false
m. = true
m.close_on_submit = true
m.header = true
m.header_divider = true
m. = true
m.padding = true
m. = true
end
config.drawer do |d|
d.position = :right
d.advance = false
d. = true
d.close_on_submit = true
d.header = true
d.header_divider = false
d. = true
d.padding = true
d. = true
d.size = :md
end
# Only used when Turbo Confirm support is enabled.
# See https://github.com/cmer/ultimate_turbo_modal#turbo-confirm
config.confirm do |c|
c.title = "Are you sure?"
c.accept_label = "OK"
c.cancel_label = "Cancel"
end
end
Per-instance options passed to modal() or drawer() override the defaults.
Modal Options
| Name | Default | Description |
|---|---|---|
advance |
false |
When opening the modal, the URL in the URL bar will change to the URL of the view being shown in the modal. The Back button dismisses the modal and navigates back. If a URL is specified as a string (e.g. advance: "/other-path"), the browser history will advance, and the URL shown in the URL bar will be replaced with the value specified. |
close_button |
true |
Shows or hide a close button (X) at the top right of the modal. |
close_on_submit |
true |
Whether a successful form submission dismisses the modal. See Closing on form submission. |
header |
true |
Whether to display a modal header. |
header_divider |
true |
Whether to display a divider below the header. |
footer_divider |
true |
Whether to display a divider above the footer. |
padding |
true |
Adds padding inside the modal. |
overlay |
true |
Whether to show a backdrop overlay. |
title |
nil |
Title to display in the modal header. Alternatively, you can set the title with a block. |
Example usage with options
<%= modal(padding: true, close_button: false, advance: false) do %>
Hello World!
<% end %>
<%= modal(padding: true, close_button: false, advance: "/foo/bar") do %>
Hello World!
<% end %>
Drawers
UTMR includes built-in drawer (slide-out panel) support. Drawers share the same <dialog> element and Stimulus controller as modals — no additional JavaScript required.
Basic Usage
Use the drawer helper instead of modal:
<%= drawer do %>
Drawer content here!
<% end %>
Link to it the same way as a modal:
<%= link_to "Open Drawer", "/settings", data: { turbo_frame: "modal" } %>
Drawer Options
| Name | Default | Description |
|---|---|---|
position |
:right |
Which edge the drawer slides from. :right or :left. |
size |
:md |
Width of the drawer. One of :xs, :sm, :md, :lg, :xl, :"2xl", :full, or a CSS string (e.g. "500px"). |
advance |
false |
When opening the drawer, the URL in the URL bar will change to the URL of the view being shown in the drawer. The Back button dismisses the drawer and navigates back. If a URL is specified as a string (e.g. advance: "/other-path"), the browser history will advance, and the URL shown in the URL bar will be replaced with the value specified. |
overlay |
true |
Whether to show a backdrop overlay behind the drawer. |
close_button |
true |
Shows or hide a close button (X). |
close_on_submit |
true |
Whether a successful form submission dismisses the drawer. See Closing on form submission. |
header |
true |
Whether to display a header. |
header_divider |
false |
Whether to display a divider below the header. |
footer_divider |
true |
Whether to display a divider above the footer. |
padding |
true |
Adds padding inside the drawer. |
title |
nil |
Title to display in the drawer header. |
<%= drawer(position: :left, size: :lg, overlay: false, title: "Settings") do %>
<p>Drawer content</p>
<% end %>
Drawer Size Reference
| Size | Max Width |
|---|---|
:sm |
24rem (384px) |
:md |
28rem (448px) |
:lg |
42rem (672px) |
:xl |
56rem (896px) |
:full |
Full viewport width minus a small gutter |
| CSS string | Custom value, e.g. "500px" or "50vw" |
Closing on form submission
By default, a form submitted inside a modal or drawer dismisses it once the
submission succeeds. Set close_on_submit: false to keep it open instead —
useful for chat composers, image uploaders, inline "add another" forms, and
anything else where the user is expected to submit repeatedly:
<%= drawer(title: "Messages", close_on_submit: false) do %>
<%= render "messages/list" %>
<%= form_with model: Message.new do |f| %>
<%= f.text_field :body %>
<%= f.submit "Send" %>
<% end %>
<% end %>
Respond with a Turbo Stream to update the contents in place, and send
turbo_stream.modal(:close) from the server on the submissions that should
dismiss it.
Two things are worth calling out:
- Failed submissions never dismiss. A 422 rendering validation errors leaves the modal open regardless of this setting, so errors are shown in place.
- Redirects still dismiss. If the server redirects to a page that doesn't
contain the modal frame, the browser is navigating away and the modal closes
(smoothly) even with
close_on_submit: false.
Per-form overrides
data-modal-close-on-submit on a form overrides the setting for that form
alone, so a single modal can mix both behaviors:
<%= drawer(title: "Messages", close_on_submit: false) do %>
<%# Stays open — inherits close_on_submit: false %>
<%= form_with model: Message.new do |f| %>
<%= f.text_field :body %>
<%= f.submit "Send" %>
<% end %>
<%# Dismisses the drawer, despite close_on_submit: false %>
<%= form_with model: @conversation, method: :delete,
data: { modal_close_on_submit: true } do |f| %>
<%= f.submit "Delete conversation" %>
<% end %>
<% end %>
It works in both directions: data: { modal_close_on_submit: false } on a form
inside a default modal keeps that one form from dismissing it. Placing the
attribute on a wrapping element applies it to every form inside; the nearest
one wins.
Turbo Confirm
Optional, opt-in. Once enabled, Turbo's data-turbo-confirm prompts render as
UTMR dialogs in your app's flavor instead of the browser's window.confirm.
Enabling it is one line in your layout:
<%= modal_confirm_template %>
That's the whole opt-in — no JavaScript changes. The helper renders an inert
<template>, and its presence on the page is the switch: with it, UTMR handles
confirmations; without it, Turbo falls back to window.confirm exactly as
before. Remove the line to turn the feature off.
You can also flip it from the initializer, which is handy for toggling per environment without touching the layout:
UltimateTurboModal.configure do |config|
config.confirm do |c|
c.enabled = false
end
end
Basic usage
Nothing changes about how you write confirmations:
<%= button_to "Delete", post_path(post), method: :delete,
form: { data: { turbo_confirm: "This can't be undone." } } %>
The message becomes the dialog body; the title and button labels come from your configured defaults.
[!NOTE] Turbo only runs confirmations for form submissions. A plain
<a>needsdata-turbo-method(ordata-turbo-stream) fordata-turbo-confirmto fire at all — that is Turbo's behavior, not UTMR's.
Customizing a single confirmation
For anything beyond the message, use the modal_confirm helper. It builds the
data attributes for you:
<%= link_to "Delete", post_path(post), data: modal_confirm(
"This can't be undone.",
title: "Delete post?",
accept: "Delete",
variant: :danger,
turbo_method: :delete) %>
| Option | Description |
|---|---|
body |
First positional argument. The message. |
title |
Dialog heading. |
accept |
Label for the confirming button. |
cancel |
Label for the dismissing button. |
variant |
:danger styles the accept button destructively and moves the initial focus to Cancel. |
native |
true uses the browser's own window.confirm for this one prompt. |
Any option you leave out keeps its configured default.
You can also write the attributes by hand, which is convenient on forms:
<%= button_to "Delete", post_path(post), method: :delete, form: { data: {
turbo_confirm: "This can't be undone.",
turbo_confirm_title: "Delete post?",
turbo_confirm_accept: "Delete",
turbo_confirm_variant: "danger" } } %>
data-turbo-confirm itself has to be on the form or on the submit button:
Turbo looks nowhere else, and a prompt written on a wrapping element is simply
never triggered. The data-turbo-confirm-* options are read once the prompt has
fired, so those may also sit on a wrapping element. The submitter wins when both
it and the form carry the same option.
[!IMPORTANT] On a link, use
modal_confirm. Siblingdata-turbo-confirm-*attributes do not survive: Turbo rewrites a link carryingdata-turbo-methodinto a hidden form and copies only a fixed set of attributes across, so they are gone before UTMR is called. The helper's JSON payload rides insidedata-turbo-confirmitself, which always survives. On forms, either style works.
Defaults
UltimateTurboModal.configure do |config|
config.confirm do |c|
c.enabled = true
c.title = "Are you sure?"
c.accept_label = "OK"
c.cancel_label = "Cancel"
c. = false # a confirm has its own Cancel button
c.header_divider = false # dividers chop up one or two lines of text
c. = false
c.header = true
c.padding = true
c. = true
end
end
The confirm dialog is deliberately plainer than a modal: no close button and no
dividers by default, and a set width (floor 20rem, cap 28rem) so a one-word
prompt and a three-line one come out the same size. Adjust it by overriding
CONFIRM_CONTENT_CLASSES in your flavor file.
Behavior
- The dialog is appended to
<body>, so it layers above an open modal or drawer without any extra setup. - ESC and the Cancel button both cancel — the action does not run.
- Clicking the backdrop does nothing. A confirm has to be answered, so a stray click never decides it.
- The confirming button is focused on open, so Enter accepts.
variant: :dangerfocuses Cancel instead. - Accepting waits for the close animation to finish before the request is sent.
window.modalkeeps pointing at the modal underneath, soturbo_stream.modal(:close)still addresses the right dialog.data-turbo-confirm-nativeon a form or submitter opts that one confirmation back out towindow.confirm. On a link, passnative: truetomodal_confirminstead, for the same reason the other options have to travel in the payload.- If something else in your app assigns
Turbo.config.forms.confirmafter UTMR loads, it wins. ImportenableModalConfirmfrom the package and call it afterwards to take the hook back.
Styling
The dialog is cloned from the template, so it shares your flavor's dialog,
backdrop and transition classes. Every slot falls back to its MODAL_* class
unless the flavor defines a CONFIRM_* override, so you only need to define
what should differ:
| Constant | Falls back to |
|---|---|
CONFIRM_INNER_CLASSES |
MODAL_INNER_CLASSES |
CONFIRM_CONTENT_CLASSES |
MODAL_CONTENT_CLASSES |
CONFIRM_HEADER_CLASSES |
MODAL_HEADER_CLASSES |
CONFIRM_TITLE_CLASSES |
MODAL_TITLE_CLASSES |
CONFIRM_TITLE_H_CLASSES |
MODAL_TITLE_H_CLASSES |
CONFIRM_MAIN_CLASSES |
MODAL_MAIN_CLASSES |
CONFIRM_FOOTER_CLASSES |
MODAL_FOOTER_CLASSES |
CONFIRM_BODY_CLASSES |
— (confirm only) |
CONFIRM_ACTIONS_CLASSES |
— (confirm only) |
CONFIRM_ACCEPT_CLASSES |
— (confirm only) |
CONFIRM_CANCEL_CLASSES |
— (confirm only) |
Upgrading from an earlier version? Run
rails generate ultimate_turbo_modal:update to refresh your flavor file — until
you do, the confirm dialog falls back to the modal's classes and the four
confirm-only slots render unstyled rather than raising.
Opening a Modal from a Drawer
You don't need to do anything special. Use data-turbo-frame="modal" like you would anywhere else, and UTMR handles the rest:
<%= drawer(title: "Notifications") do %>
<p>Activity list here…</p>
<%= link_to "Edit preferences",
edit_preferences_path,
data: { turbo_frame: "modal" } %>
<% end %>
<%= modal(title: "Notification preferences") do %>
<p>Form here…</p>
<% end %>
The same partial works inside a drawer or out — outside, it opens a regular modal; inside, it stacks on top of the drawer.
Behavior
- ESC closes the modal first, then the drawer (native top-layer behavior).
- Click outside the modal closes the modal only; the drawer stays open.
turbo_stream.modal(:close)closes the topmost dialog (the modal).- Form submission with same-page redirect closes the modal smoothly; the drawer stays.
- Form submission with a different-page redirect closes both dialogs, then navigates.
- Closing the drawer (via close button, ESC after the modal closes, etc.) also tears down any modal opened from it.
Constraints
- Modal-from-drawer only. You cannot open a drawer from inside a modal, and you cannot stack a modal on top of another modal. The
drawer-modalframe is only rendered inside drawers. - Stacked modals always force
advance: false(history is not pushed). All other modal options (overlay, padding, header, footer, etc.) work normally. - Both backdrops are drawn when both dialogs have
overlay: true. Passoverlay: falseto the inner modal if you don't want the drawer to look slightly darker while the modal is open.
For a full lifecycle walkthrough and edge-case notes, see docs/modal-from-drawer.md.
Features and capabilities
- Extremely easy to use
- Built-in drawer (slide-out panel) support with left/right positioning and configurable sizes
- Fully responsive
- Does not break if a user navigates directly to a page that is usually shown in a modal
- Opening a modal in a new browser tab (ie: right click) gracefully degrades without having to code a modal and non-modal version of the same page
- Automatically handles URL history (ie: pushState) for shareable URLs
- pushState URL optionally overrideable
- Seamless support for multi-page navigation within the modal
- Seamless support for forms with validations
- Seamless support for Rails flash messages
- Support for long, scrollable modals
- Properly locks the background page when scrolling a long modal
- Click outside the modal to dismiss
- Option to whitelist CSS selectors that won't dismiss the modal when clicked outside the modal (see body-appended widgets guide for datepickers and similar popups)
- Keyboard control; ESC to dismiss
- Automatic (or not) close button
- Native focus trapping via the
<dialog>element for improved accessibility (Tab and Shift+Tab cycle through focusable elements within the modal only) - Smooth redirects: form submissions that redirect back to the same page morph the content behind the modal before closing; redirects to a different page close the modal with animation first, then navigate
- Optional Turbo Confirm support:
data-turbo-confirmprompts render as a styled dialog instead of the browser'swindow.confirm
Running the Demo Application
The repository includes a demo application in the demo-app directory that showcases all the features of Ultimate Turbo Modal. To run it locally:
# Navigate to the demo app directory
cd demo-app
# Start the development server
bin/dev
# Open your browser
open http://localhost:3000
Upgrading
Please see the Upgrading Guide for detailed instructions on upgrading between versions.
Thanks
Thanks to @joeldrapper and @konnorrogers for all the help!
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/cmer/ultimate_turbo_modal.
License
The gem is available as open source under the terms of the MIT License.
