.confirm-dialog {
border: none;
border-radius: 12px;
padding: 1.5rem;
max-width: 26rem;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.25);
}
.confirm-dialog::backdrop {
background: rgba(15, 23, 42, 0.55);
backdrop-filter: blur(2px);
}
.confirm-dialog menu {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin: 1.25rem 0 0;
padding: 0;
}
.confirm-dialog input {
display: block;
width: 100%;
margin-top: 0.35rem;
padding: 0.5rem;
}
.btn--danger[disabled] {
opacity: 0.5;
cursor: not-allowed;
}
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["dialog", "confirm", "input"]
static values = { phrase: String }
connect() {
this.pendingSubmitter = null
}
submit(event) {
if (this.dialogTarget.open) return
event.preventDefault()
this.pendingSubmitter = event.submitter || null
if (this.hasInputTarget) {
this.inputTarget.value = ""
this.lockConfirm()
}
this.dialogTarget.showModal()
if (this.hasInputTarget) this.inputTarget.focus()
}
validate() {
const typed = this.inputTarget.value.trim()
if (typed === this.phraseValue) {
this.unlockConfirm()
} else {
this.lockConfirm()
}
}
confirmed() {
this.dialogTarget.close("confirmed")
// requestSubmit preserves the clicked button and native validation
this.element.requestSubmit(this.pendingSubmitter)
this.pendingSubmitter = null
}
cancel() {
this.dialogTarget.close("cancelled")
this.pendingSubmitter = null
}
lockConfirm() {
if (this.hasConfirmTarget) this.confirmTarget.disabled = true
}
unlockConfirm() {
if (this.hasConfirmTarget) this.confirmTarget.disabled = false
}
disconnect() {
if (this.dialogTarget.open) this.dialogTarget.close()
this.pendingSubmitter = null
}
}
<%= form_with url: project_path(project), method: :delete,
data: {
controller: "confirm",
action: "submit->confirm#submit",
confirm_phrase_value: project.name
} do |form| %>
<%= form.submit "Delete project", class: "btn btn--danger" %>
<dialog data-confirm-target="dialog" class="confirm-dialog">
<h2>Delete <%= project.name %>?</h2>
<p>This permanently removes the project and all of its tasks.</p>
<label>
Type <strong><%= project.name %></strong> to confirm
<input type="text"
data-confirm-target="input"
data-action="input->confirm#validate"
autocomplete="off">
</label>
<menu>
<button type="button" data-action="confirm#cancel">Cancel</button>
<button type="button"
class="btn btn--danger"
data-confirm-target="confirm"
data-action="confirm#confirmed"
disabled>
Delete forever
</button>
</menu>
</dialog>
<% end %>
This snippet shows a resilient confirmation pattern for destructive actions built as a Stimulus controller, backed by a Rails view that wires it up declaratively. The goal is a confirmation flow that never leaves a delete button in a broken state: if JavaScript fails to load, the plain form still submits; if it loads, the controller intercepts the submission and gates it behind a dialog.
The confirm_controller.js tab treats the native <dialog> element as the confirmation surface rather than the blocking window.confirm, which cannot be styled and freezes the page. In submit(event), the controller calls event.preventDefault() only after confirming the element is trusted, then opens the dialog with showModal(). The key resilience detail is that the original submit event is captured and replayed: this.pendingSubmitter remembers which button was clicked so the correct formmethod/formaction survives the round trip. For high-risk actions the controller supports a typed guard via the phrase value — the user must type an exact confirmation string before confirmTarget enables, defeating muscle-memory clicks.
The validate handler compares the trimmed input against phraseValue and toggles the disabled attribute, and confirmed() finally submits the real form with requestSubmit(this.pendingSubmitter) so Turbo and native validation both still fire. disconnect() guards against leaks by closing any open dialog, which matters because Turbo caches and restores pages without full reloads.
The _delete_button.html.erb tab is the progressive-enhancement half: the form carries data-controller, data-action on submit, and the data-confirm-phrase-value, so the same markup degrades gracefully. Note that data-turbo-confirm is deliberately omitted because this controller replaces it with a richer flow.
The confirm_dialog.css tab styles the ::backdrop and constrains focus visually. This approach is worth reaching for when destructive actions need real accessibility, custom copy, or a typed guard that window.confirm and data-turbo-confirm cannot provide, while keeping a working no-JS fallback. The main pitfall is forgetting that dialog.close() fires a close event, so the controller must distinguish cancel from confirm to avoid double submission.
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.