Turbo confirmation dialogs: native by default, custom when needed
Rails and Turbo make destructive-action confirmations pleasantly boring:
<%= button_to "Delete", post_path(@post), method: :delete,
form: { data: { turbo_confirm: "Delete this post?" } } %>
Turbo calls the browser’s native window.confirm(). That is still my default
recommendation.
This post originally recommended replacing it with a styled Tailwind dialog. After running that approach in a real Turbo application, fixing its lifecycle bugs, adding richer options, and eventually removing it, my conclusion changed: keep the native dialog unless the confirmation genuinely needs more than a question and two buttons.
The best default: do nothing #
The native dialog gives you, for free:
- correct promise handling by Turbo;
- keyboard and focus behavior maintained by the browser;
- no listeners to leak between confirmations;
- no stale DOM references after Turbo replaces the
<body>; - a working fallback in every Rails layout and native shell.
For button_to, put data-turbo-confirm on the generated form with
form: { data: ... }, as above. For non-GET actions, prefer a real button and
form over a link. The Turbo handbook
makes the same accessibility recommendation.
If a normal confirmation is all the product needs, stop here.
Why the styled version was not drop-in #
My first implementation followed the common pattern of rendering one
<dialog> in the layout and assigning a custom function to
Turbo.config.forms.confirm. It worked on the first page load, then accumulated
all the edge cases hidden by the short examples:
- Turbo navigation replaces the body. DOM nodes cached when the module was imported pointed to detached elements after navigation. Every lookup must happen inside the confirmation callback.
-
Every Turbo-enabled layout needs the dialog. Missing markup must fall
back to
window.confirm(), never silently resolvetrue. -
Listeners accumulate. A
{ once: true }close handler is not enough once input, keyboard, backdrop, and cancel handlers are added. Tear down the whole group after every close. -
Text inserted with HTML is unsafe. Confirmation messages and descriptions
must be assigned with
textContent, not interpolated into an HTML string. -
The dialog has state. Button labels, disabled state, input value,
aria-describedby, andreturnValuemust be reset on every opening. - Focus is a product decision. Focusing the destructive button makes an accidental Enter press dangerous. Focus Cancel by default; focus the input only for typed confirmation.
-
Escape and backdrop clicks mean Cancel. The promise must resolve
falseon every dismissal path.
The hook itself remains supported. Turbo’s current API is
Turbo.config.forms.confirm,
and the function must return a promise resolving to true or false.
When a custom dialog earns its keep #
Use one when you have a concrete requirement such as:
- explaining an irreversible consequence;
- requiring the user to type a record name;
- using action-specific button labels;
- presenting information that does not fit in a browser confirmation.
Do not build it merely to make the confirmation match the rest of the UI.
If you do need it, this is the complete pattern I would use now.
1. Render one dialog in every relevant layout #
<%# app/views/shared/_turbo_confirm_dialog.html.erb %>
<dialog id="turbo-confirm-dialog"
class="w-full max-w-md rounded-lg p-0 backdrop:bg-black/50"
aria-labelledby="turbo-confirm-title">
<form method="dialog" class="space-y-4 p-6">
<h2 id="turbo-confirm-title" class="text-lg font-semibold"></h2>
<p id="turbo-confirm-description" class="text-sm text-gray-600" hidden></p>
<div id="turbo-confirm-text-wrapper" class="space-y-2" hidden>
<label for="turbo-confirm-text-input" class="block text-sm">
Type <code id="turbo-confirm-required-text"></code> to confirm
</label>
<input id="turbo-confirm-text-input"
type="text"
class="w-full rounded border px-3 py-2"
autocomplete="off">
</div>
<div class="flex justify-end gap-2">
<button id="turbo-confirm-reject"
type="submit"
value="cancel"
class="rounded px-4 py-2"
autofocus>
Cancel
</button>
<button id="turbo-confirm-accept"
type="submit"
value="confirm"
class="rounded bg-red-600 px-4 py-2 text-white">
Confirm
</button>
</div>
</form>
</dialog>
Render it near the end of <body>:
<%= render "shared/turbo_confirm_dialog" %>
Keep the markup in a partial, but remember that the partial must be rendered by every layout that can submit a Turbo-confirmed form.
2. Override Turbo’s confirmation function #
// app/javascript/turbo_confirm.js
import { Turbo } from "@hotwired/turbo-rails"
Turbo.config.forms.confirm = (message, element, submitter) => {
// Query on every call. Turbo navigation may have replaced the entire body.
const dialog = document.getElementById("turbo-confirm-dialog")
const title = document.getElementById("turbo-confirm-title")
const description = document.getElementById("turbo-confirm-description")
const textWrapper = document.getElementById("turbo-confirm-text-wrapper")
const requiredTextElement = document.getElementById("turbo-confirm-required-text")
const textInput = document.getElementById("turbo-confirm-text-input")
const acceptButton = document.getElementById("turbo-confirm-accept")
const rejectButton = document.getElementById("turbo-confirm-reject")
const requiredElements = [
dialog,
title,
description,
textWrapper,
requiredTextElement,
textInput,
acceptButton,
rejectButton
]
// Missing or incomplete markup must not bypass confirmation.
if (!(dialog instanceof HTMLDialogElement) || !requiredElements.every(Boolean)) {
return Promise.resolve(window.confirm(message))
}
const submitterData = submitter?.dataset || {}
const elementData = element?.dataset || {}
const data = { ...elementData, ...submitterData }
const detail = data.turboConfirmDescription || ""
const requiredText = data.turboConfirmText || ""
const acceptLabel = data.turboConfirmAccept || ""
const rejectLabel = data.turboConfirmReject || ""
const defaultAcceptLabel = acceptButton.textContent.trim()
const defaultRejectLabel = rejectButton.textContent.trim()
const controller = new AbortController()
const { signal } = controller
// Use textContent: these values may contain user-controlled record names.
title.textContent = message
description.textContent = detail
description.hidden = !detail
dialog.toggleAttribute("aria-describedby", Boolean(detail))
if (detail) dialog.setAttribute("aria-describedby", description.id)
requiredTextElement.textContent = requiredText
textInput.value = ""
textWrapper.hidden = !requiredText
acceptButton.disabled = Boolean(requiredText)
acceptButton.textContent = acceptLabel || defaultAcceptLabel
rejectButton.textContent = rejectLabel || defaultRejectLabel
// Escape should always resolve as Cancel, even if a previous use confirmed.
dialog.returnValue = "cancel"
if (requiredText) {
textInput.addEventListener("input", () => {
acceptButton.disabled = textInput.value !== requiredText
}, { signal })
textInput.addEventListener("keydown", event => {
if (event.key !== "Enter") return
event.preventDefault()
if (!acceptButton.disabled) dialog.close("confirm")
}, { signal })
}
dialog.addEventListener("click", event => {
if (event.target === dialog) dialog.close("cancel")
}, { signal })
dialog.showModal()
requiredText ? textInput.focus() : rejectButton.focus()
return new Promise(resolve => {
dialog.addEventListener("close", () => {
const confirmed = dialog.returnValue === "confirm"
controller.abort()
textInput.value = ""
acceptButton.disabled = false
acceptButton.textContent = defaultAcceptLabel
rejectButton.textContent = defaultRejectLabel
resolve(confirmed)
}, { once: true })
})
}
Import it after Turbo:
// app/javascript/application.js
import "@hotwired/turbo-rails"
import "turbo_confirm"
With importmap:
# config/importmap.rb
pin "turbo_confirm"
3. Put the options on the form #
<%= button_to "Delete account", account_path, method: :delete,
form: {
data: {
turbo_confirm: "Delete this account permanently?",
turbo_confirm_description: "This removes the account and its data.",
turbo_confirm_text: account.name,
turbo_confirm_accept: "Delete account",
turbo_confirm_reject: "Keep account"
}
} %>
The callback checks the clicked submitter first and then the form, so the same implementation also supports forms with multiple submit buttons.
Verification checklist #
Before calling a custom confirmation complete, test all of these:
- initial page load;
- after at least one Turbo navigation;
- every layout that contains a destructive action;
- Cancel, Escape, backdrop click, Confirm, and repeated openings;
- required-text mismatch, match, and Enter-key submission;
- custom labels followed by a normal confirmation, proving state was reset;
- the missing-dialog fallback;
- any Hotwire Native shell that can reach the action.
The implementation is much longer than the first version of this post. That is the point: the browser dialog is the best practice for the ordinary case because the browser already owns this complexity.
Inspired by the Boring Rails dialog pattern, Rails Designer, and the bugs found while operating the pattern in production.
Did you like this article? Did it save you some time?