Beautiful Rails confirmation dialogs (with zero JavaScript)
This is a guest collaboration with Stephen Margheim, creator of High Leverage Rails, a video course on building high-quality Rails applications with the power and simplicity of SQLite, HTML, and CSS.
Turbo’s data-turbo-confirm attribute is convenient for quick confirmation dialogs, but the native confirm() prompt it triggers looks dated and out of place. If you want a styled confirmation dialog that matches your app’s design, the traditional approach recommends a lot of JavaScript — a Stimulus controller to open and close the dialog, event listeners for keyboard handling, and coordination between the trigger and the modal.
But, recent browser updates have changed the game. Invoker Commands landed in Chrome 131 and Safari 18.4, giving us declarative dialog control. Combined with @starting-style for animations, we can now build beautiful, animated confirmation dialogs without writing any JavaScript.
| Feature | How… |
|---|---|
| Open dialog | command="show-modal" on a button |
| Close dialog | command="close" on cancel button |
| Escape key | Built-in browser behavior |
| Light dismiss | closedby="any" attribute |
| Enter animation | @starting-style CSS rule |
| Exit animation | allow-discrete on display transition |
Let’s imagine we wanted a confirmation dialog for when a user decides to delete an item in our app. Here is how we could build such a dialog today using modern browser features with zero JavaScript:
<button type="button" commandfor="delete-item-dialog" command="show-modal">
Delete this item
</button>
<dialog id="delete-item-dialog" closedby="any" role="alertdialog"
aria-labelledby="dialog-title" aria-describedby="dialog-desc">
<header>
<hgroup>
<h3 id="dialog-title">Delete this item?</h3>
<p id="dialog-desc">Are you sure you want to permanently delete this item?</p>
</hgroup>
</header>
<footer>
<button type="button" commandfor="delete-item-dialog" command="close">
Cancel
</button>
<%= button_to item_path(item), method: :delete do %>
Delete item
<% end %>
</footer>
</dialog>
The command attribute tells the browser what action to perform, and commandfor specifies the target element by id. With command="show-modal", clicking the button calls showModal() on the target dialog. The cancel button uses command="close" to call the dialog’s close() method. Note the cancel button is type="button", not type="submit" — we don’t want it participating in any form submission.
Modal dialogs opened with showModal() automatically close on Escape. The browser handles it. Adding closedby=”any” enables “light dismiss” — clicking the backdrop closes the dialog too.
The aria-labelledby and aria-describedby attributes connect the dialog to its heading and description. Screen reader users hear both the title and explanatory text announced immediately, giving them full context before making a decision. And role="alertdialog" signals the dialog is a confirmation window communicating an important message that requires a user response.
Interactive demo
Here’s a working demo showing how different close mechanisms work. Try clicking “Delete Item”, then close it different ways: click Cancel, click Delete, press Escape, or click the backdrop. Notice how returnValue is only "confirm" when you click the Delete button.
Event Log
So much functionality with nothing but declarative HTML! I love it.
Adding Animations with @starting-style
For a polished feel, add smooth enter/exit transitions. With @starting-style and allow-discrete, we can animate dialogs purely in CSS.
The @starting-style rule defines the initial state when an element first appears. Without it, the browser renders the dialog immediately in its final state. With it, the browser starts from opacity: 0; scale: 0.95 and transitions to opacity: 1; scale: 1, for example.
For exit animations, we need transition-behavior: allow-discrete on display and overlay. Most CSS properties are continuous — opacity can be 0.5, colors can blend. But display is discrete: it’s either none or block, with no intermediate values. Historically, this meant display changes couldn’t animate.
The allow-discrete keyword tells the browser to apply transition timing even for discrete properties. For closing animations, the browser keeps the element visible, runs the exit transition, then flips to display: none only after the transition completes. The overlay property works similarly — it controls whether the dialog stays in the top layer during the transition.
dialog {
opacity: 1;
scale: 1;
transition:
opacity 0.2s ease-out,
scale 0.2s ease-out,
overlay 0.2s ease-out allow-discrete,
display 0.2s ease-out allow-discrete;
@starting-style {
opacity: 0;
scale: 0.95;
}
}
dialog:not([open]) {
opacity: 0;
scale: 0.95;
}
dialog::backdrop {
background-color: rgb(0 0 0 / 0.5);
transition:
background-color 0.2s ease-out,
overlay 0.2s ease-out allow-discrete,
display 0.2s ease-out allow-discrete;
@starting-style {
background-color: rgb(0 0 0 / 0);
}
}
dialog:not([open])::backdrop {
background-color: rgb(0 0 0 / 0);
}
Browser Support
| Feature | Chrome | Safari | Firefox | Can I Use? |
|---|---|---|---|---|
command |
135+ | 26.2+ | 144+ | link |
commandfor |
135+ | 26.2+ | 144+ | link |
@starting-style |
117+ | 17.5+ | 129+ | link |
closedby |
134+ | Not yet | 141+ | link |
Safari support for closedby is still pending. For production use today, add a polyfill: dialog-closedby-polyfill
If you need invoker command support for older browsers, there is also a polyfill for that: invokers-polyfill
Both polyfills are small and only run when native support is missing.
Integrating with Turbo’s Confirm System
Now, what if you want to keep using Turbo’s data-turbo-confirm attribute while getting a styled native dialog?
Turbo provides Turbo.config.forms.confirm for exactly this. Mikael Henriksson has an excellent writeup on this approach and Chris Oliver has a GoRails video as well.
First, add a dialog template to your layout, which you can of course style however you’d like using whatever CSS tooling you have in your app:
<%# app/views/layouts/application.html.erb %>
<dialog id="turbo-confirm-dialog" closedby="any"
aria-labelledby="turbo-confirm-title" aria-describedby="turbo-confirm-message">
<header>
<hgroup>
<h3 id="turbo-confirm-title">Confirm</h3>
<p id="turbo-confirm-message"></p>
</hgroup>
</header>
<footer>
<button type="button" commandfor="turbo-confirm-dialog" command="close">
Cancel
</button>
<form method="dialog">
<button type="submit" value="confirm">
Confirm
</button>
</form>
</footer>
</dialog>
The Confirm button is type="submit" inside a <form method="dialog">. When submitted, the browser closes the dialog and sets returnValue to the button’s value attribute. This is how we detect which button was pressed — no JavaScript event coordination needed.
Then configure Turbo:
const dialog = document.getElementById("turbo-confirm-dialog")
const messageElement = document.getElementById("turbo-confirm-message")
const confirmButton = dialog?.querySelector("button[value='confirm']")
Turbo.config.forms.confirm = (message, element, submitter) => {
// Fall back to native confirm if dialog isn't in the DOM
if (!dialog) return Promise.resolve(confirm(message))
messageElement.textContent = message
// Allow custom button text via data-turbo-confirm-button
const buttonText = submitter?.dataset.turboConfirmButton || "Confirm"
confirmButton.textContent = buttonText
dialog.showModal()
return new Promise((resolve) => {
dialog.addEventListener("close", () => {
resolve(dialog.returnValue === "confirm")
}, { once: true })
})
}
The JavaScript does only three things:
- set the message text,
- customize the button text if provided, and
- open the dialog.
Everything else — closing on button click, closing on Escape, closing on backdrop click, determining which button was pressed — is handled by the platform.
The fallback to native confirm() ensures your app still works if the dialog element is missing (e.g., on a different layout or error page).
Turbo.config.forms.confirm expects a function returning a Promise that resolves to true (proceed) or false (cancel). The function receives three arguments: the confirmation message, the element with the data-turbo-confirm attribute, and the submitter element. We listen for the close event and check returnValue. Write this handler once, add one dialog to your layout, and every data-turbo-confirm in your app uses it.
You can customize the confirm button text per-trigger using data-turbo-confirm-button:
<%= button_to item_path(item),
method: :delete,
data: {
turbo_confirm: "Are you sure you want to delete this item?",
turbo_confirm_button: "Delete item"
} do %>
Delete
<% end %>
This produces a more contextual confirmation dialog with “Delete item” instead of a generic “Confirm” button — better UX that makes the action clear.
Addendum: Preventing Background Scroll
One common modal requirement is preventing page scroll while the dialog is open:
body:has(dialog:modal) {
overflow: hidden;
}
The :modal pseudo-class matches dialogs opened with showModal(). Combined with :has(), this selector targets the body only when a modal dialog is open. When the dialog opens, scrolling stops. When it closes, scrolling resumes. The browser handles the coordination.
Was this article valuable? Subscribe to the low-volume, high-signal newsletter. No spam. All killer, no filler.