Blocks

Dialog

A modal overlay with a backdrop, animated panel, and close button — composes trigger, content, header, footer, title, and description helpers.

Installation

bin/rails g shadcnrb:component dialog

Usage

The block is the dialog panel; dialog.trigger marks what opens it — you write that element yourself with any helper, the same shape as sui.tooltip and sui.dropdown_menu. Child helpers (dialog.header, ...) live on the proxy.

Welcome

This is a basic dialog example.

Dialog body content goes here.

<%= sui.dialog do |dialog| %>
  <%= dialog.trigger { sui.button "Open dialog", variant: :outline } %>
  <%= dialog.header do %>
    <%= dialog.title "Welcome" %>
    <%= dialog.description "This is a basic dialog example." %>
  <% end %>
  <p class="text-sm">Dialog body content goes here.</p>
<% end %>

Confirmation dialog

Use dialog.footer to place action buttons at the bottom.

Are you sure?

This action cannot be undone.

<%= sui.dialog do |dialog| %>
  <%= dialog.trigger { sui.button "Delete account", variant: :destructive } %>
  <%= dialog.header do %>
    <%= dialog.title "Are you sure?" %>
    <%= dialog.description "This action cannot be undone." %>
  <% end %>
  <%= dialog.footer do %>
    <%= dialog.close "Cancel", variant: :outline %>
    <%= dialog.close "Yes, delete", variant: :destructive %>
  <% end %>
<% end %>

Profile edit dialog

Compose form fields inside the content area using sui.label and sui.input.

Edit profile

Update your display name and email address.

<%= sui.dialog do |dialog| %>
  <%= dialog.trigger { sui.button "Edit profile", variant: :outline } %>
  <%= dialog.header do %>
    <%= dialog.title "Edit profile" %>
    <%= dialog.description "Update your display name and email address." %>
  <% end %>
  <div class="grid gap-4 py-2">
    <div class="grid gap-1.5">
      <%= sui.label "Name", for: "dialog-name" %>
      <%= sui.input id: "dialog-name", placeholder: "Your name" %>
    </div>
    <div class="grid gap-1.5">
      <%= sui.label "Email", for: "dialog-email" %>
      <%= sui.input id: "dialog-email", type: "email", placeholder: "[email protected]" %>
    </div>
  </div>
  <%= dialog.footer do %>
    <%= sui.button "Save changes" %>
  <% end %>
<% end %>

Lazy-loaded content

Pass src: to sui.dialog and the panel renders a Turbo Frame that fetches content when opened. The block body becomes the loading state.

<%= sui.dialog src: lazy_dialog_content_path do |dialog| %>
  <%= dialog.trigger { sui.button "Lazy-load demo", variant: :outline } %>
  <div class="grid gap-3 py-4" aria-hidden="true">
    <%= sui.skeleton class: "h-4 w-40" %>
    <%= sui.skeleton class: "h-9" %>
    <%= sui.skeleton class: "h-9" %>
  </div>
<% end %>

The server endpoint wraps its response in a matching <turbo-frame> tag. Content loads once on first open, then stays cached for subsequent opens. The lazy-loaded partial reaches dialog parts via sui.dialog_proxy. The frame id is a digest of the src URL, so every frame for a given URL requests the same id and responses are safe to cache publicly (browser or CDN). reload: re-issues the request on every open through normal HTTP caching — give the endpoint no_store when each open must hit the server.

Reload on every open

Pass reload: true to re-fetch content every time the dialog opens, instead of caching the first response. The loading state is restored on close so the user sees it again on the next open.

<%= sui.dialog src: lazy_dialog_content_path, reload: true do |dialog| %>
  <%= dialog.trigger { sui.button "Reload demo", variant: :outline } %>
  <div class="grid gap-3 py-4" aria-hidden="true">
    <%= sui.skeleton class: "h-4 w-40" %>
    <%= sui.skeleton class: "h-9" %>
    <%= sui.skeleton class: "h-9" %>
  </div>
<% end %>

Open this dialog multiple times. Each open shows "Loading..." briefly, then re-requests the content (1s simulated delay; on this publicly cached site, repeat opens within the hour are served from the browser cache).

Trigger kwarg

For the common "button opens a dialog" case, skip the wrapper block: pass dialog: to sui.button with the dialog's options and the button becomes the trigger. The hash takes everything sui.dialog takes — loading: sets a custom loading state, since there's no block to put it in. The block form stays for inline panel markup.

Loading...

<%= sui.button "Lazy-load demo", variant: :outline, dialog: { src: lazy_dialog_content_path } %>

<%= sui.button "Custom loading", variant: :outline,
      dialog: { src: lazy_dialog_content_path, reload: true,
                loading: sui.skeleton(class: "m-6 h-24") } %>

Detached trigger

Pass a String instead — dialog: "an-id" — and the button opens the dialog with that id, wherever both live in the page. Under the hood it's just data-dialog="an-id", the same way data-turbo-frame targets a frame — so any element can be a trigger, and several can share one dialog.

from a plain link

Loading...

<div class="flex items-center gap-3">
  <%= sui.button "From a button", variant: :outline, dialog: "detached-profile-dialog" %>
  <%= link_to "from a plain link", "#", class: "text-sm underline underline-offset-4", data: { dialog: "detached-profile-dialog" } %>
</div>

<%= sui.dialog src: lazy_dialog_content_path, id: "detached-profile-dialog" %>

Server-driven confirmation

Sometimes the server needs to warn the user mid-request. The delete action hits the server, the server detects the operation is risky (e.g., chart has active users), and responds with a Turbo Stream that appends a confirmation dialog. The OK button re-submits the same request with confirm=true, and this time the server does the work.

Try both: chart 1 is "risky" (shows confirmation), chart 2 is "safe" (deletes immediately).

<div class="flex gap-2">
  <%= button_to "Delete chart 1 (risky)", destroy_demo_chart_path(1),
        method: :delete,
        form: { data: { turbo_stream: true } },
        class: "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap cursor-pointer h-9 px-4 py-2 bg-destructive text-white hover:bg-destructive/90" %>
  <%= button_to "Delete chart 2 (safe)", destroy_demo_chart_path(2),
        method: :delete,
        form: { data: { turbo_stream: true } },
        class: "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap cursor-pointer h-9 px-4 py-2 border bg-background shadow-xs hover:bg-accent" %>
</div>

API

MethodArgsDefaultDescription
sui.dialogopen:, src:, reload:, content:, **optsfalse, nil, falseWrapper; the block body is the panel (backdrop + centered card). open: true starts open — a server-rendered confirmation just omits the trigger. src: lazy-loads via Turbo Frame (body = loading state); reload: true re-fetches on every open. content: is the panel's option hash
sui.dialog_proxyReturns a bare proxy for lazy-loaded partials rendered inside a Turbo Frame
dialog.trigger&blockMarks which part of the block is the trigger; your markup, verbatim — click-to-open is bound on the wrapper. Mark one element data-slot="dialog-trigger" to scope it in composite triggers
dialog.closename, **optsWraps content so clicking it closes the dialog (use in footer)
dialog.header**optsFlex column container for title and description
dialog.footer**optsRow container for action buttons, right-aligned on sm+
dialog.titlenamenilSemibold heading rendered as h2
dialog.descriptionnamenilMuted supporting text rendered as p