import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["dialog", "message"]
connect() {
this.pendingElement = null
}
confirm(event) {
event.preventDefault()
event.stopImmediatePropagation()
this.pendingElement = event.currentTarget
const message = this.pendingElement.dataset.confirmMessage || "Are you sure?"
this.messageTarget.textContent = message
this.dialogTarget.showModal()
}
accept() {
const el = this.pendingElement
this.dialogTarget.close()
if (!el) return
const form = el.closest("form")
if (form && el.type === "submit") {
form.requestSubmit(el)
} else {
el.removeAttribute("data-confirm-message")
el.dataset.action = (el.dataset.action || "").replace("click->confirm#confirm", "").trim()
el.click()
}
this.pendingElement = null
}
cancel() {
this.dialogTarget.close()
this.pendingElement = null
}
}
<div data-controller="confirm">
<dialog data-confirm-target="dialog"
class="confirm-dialog"
aria-labelledby="confirm-title"
aria-describedby="confirm-message">
<h2 id="confirm-title">Please confirm</h2>
<p id="confirm-message" data-confirm-target="message"></p>
<div class="confirm-dialog__actions">
<button type="button"
class="btn btn--ghost"
data-action="confirm#cancel">
Cancel
</button>
<button type="button"
class="btn btn--danger"
data-action="confirm#accept"
autofocus>
Yes, continue
</button>
</div>
</dialog>
<%= yield %>
</div>
<%= render layout: "shared/confirmable" do %>
<h1>Posts</h1>
<ul class="post-list">
<% @posts.each do |post| %>
<li>
<%= link_to post.title, post %>
<%= button_to "Delete",
post_path(post),
method: :delete,
class: "btn btn--danger",
data: {
action: "click->confirm#confirm",
confirm_message: "Delete \"#{post.title}\"? This cannot be undone."
} %>
<%= link_to "Archive",
archive_post_path(post),
data: {
turbo_method: :patch,
action: "click->confirm#confirm",
confirm_message: "Move this post to the archive?"
} %>
</li>
<% end %>
</ul>
<% end %>
The default window.confirm blocks the main thread, cannot be styled, and does not integrate with Turbo's link/form handling. This snippet replaces it with a reusable <dialog>-based confirmation flow driven by a Stimulus controller, so destructive actions get a real, accessible, themeable modal while still working with Turbo.
In confirm_controller.js, the controller registers a single shared dialogTarget and a messageTarget. When any element opts into confirmation, confirm(event) first calls event.preventDefault() and event.stopImmediatePropagation() — the second call is important because it stops Turbo's own click handler from firing before the user answers. The originating element is stashed in this.pendingElement, the dialog's message is populated from the element's data-confirm-message attribute, and the native dialog.showModal() opens a top-layer modal with a proper backdrop and focus trap for free.
The resolution logic lives in accept() and cancel(). On accept, the controller re-dispatches the action on the stored element: a form is submitted with requestSubmit() (which respects validation and Turbo), while a link's click() is re-triggered after temporarily removing the data-confirm marker so the handler does not loop. Using requestSubmit() rather than submit() matters because it fires the submit event Turbo listens for.
The Confirmable partial renders the dialog markup once per page and wires the buttons to the controller with data-action. The <dialog> element is inert until opened, and aria-labelledby/aria-describedby keep it announced to screen readers.
Finally posts/index.html.erb shows the consumer side: a button_to and a plain link_to both get data: { action: 'click->confirm#confirm', confirm_message: '...' }. Any element can now request confirmation without knowing anything about the dialog.
The main trade-off is that <dialog> requires a modern browser and the interception depends on event ordering, so stopImmediatePropagation is load-bearing. The payoff is a consistent, styled, accessible confirm that removes every raw window.confirm from the app while remaining fully compatible with Turbo Drive.
Related snips
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
scope :draft, -> { where(published_at: nil) }
ActiveRecord scopes for reusable query logic
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Semantic HTML Example</title>
Semantic HTML5 elements and accessibility best practices
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
Share this code
Here's the card — post it anywhere.