Boring Rails

Beautiful Rails confirmation dialogs (with zero JavaScript)

Dec 15th, 2025 13 min read

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.

Delete this item?

This action cannot be undone.

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:

    1. set the message text,
    2. customize the button text if provided, and
    3. 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.