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.
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
| Method | Args | Default | Description |
|---|---|---|---|
sui.dialog | open:, src:, reload:, content:, **opts | false, nil, false | Wrapper; 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_proxy | — | — | Returns a bare proxy for lazy-loaded partials rendered inside a Turbo Frame |
dialog.trigger | &block | — | Marks 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.close | name, **opts | — | Wraps content so clicking it closes the dialog (use in footer) |
dialog.header | **opts | — | Flex column container for title and description |
dialog.footer | **opts | — | Row container for action buttons, right-aligned on sm+ |
dialog.title | name | nil | Semibold heading rendered as h2 |
dialog.description | name | nil | Muted supporting text rendered as p |