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 %>

Sizing

The panel is w-full up to sm:max-w-lg, so every dialog is the same width. Size it with classes on content: — conflicting utilities replace the defaults. sm:w-fit shrinks the panel to its content from sm up and leaves phones full width; the max-w cap still keeps it inside the viewport.

Sign out?

You can sign back in at any time.

<%= sui.dialog content: { class: "sm:w-fit" } do |dialog| %>
  <%= dialog.trigger { sui.button "Content-sized", variant: :outline } %>
  <%= dialog.header do %>
    <%= dialog.title "Sign out?" %>
    <%= dialog.description "You can sign back in at any time." %>
  <% end %>
  <%= dialog.footer do %>
    <%= dialog.close "Cancel", variant: :outline %>
    <%= dialog.close "Sign out" %>
  <% end %>
<% end %>

Responsive prefixes step the width with the page: a wide layout gets a wide panel, a phone gets the full-width default.

Choose a plan

Cards stack on a phone and sit side by side from md up.

Starter

Everything in the tier below, plus more seats.

Team

Everything in the tier below, plus more seats.

Enterprise

Everything in the tier below, plus more seats.

<%= sui.dialog content: { class: "sm:max-w-md md:max-w-2xl lg:max-w-4xl" } do |dialog| %>
  <%= dialog.trigger { sui.button "Responsive width", variant: :outline } %>
  <%= dialog.header do %>
    <%= dialog.title "Choose a plan" %>
    <%= dialog.description "Cards stack on a phone and sit side by side from md up." %>
  <% end %>
  <div class="grid gap-4 md:grid-cols-3">
    <% %w[Starter Team Enterprise].each do |plan| %>
      <%= sui.card(class: "p-4 space-y-1") do %>
        <p class="font-medium"><%= plan %></p>
        <p class="text-sm text-muted-foreground">Everything in the tier below, plus more seats.</p>
      <% end %>
    <% end %>
  </div>
<% 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 to the layout's sui.overlay_outlet. The OK button re-submits the same request with confirm=true, and this time the server does the work.

# In app/views/layouts/application.html.erb
sui.overlay_outlet

# In a controller
render turbo_stream: turbo_stream.append(
  Shadcnrb::BaseBuilder::OVERLAY_OUTLET_ID, partial: "charts/destroy_confirmation"
)

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_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.closename, **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.titlenamenilSemibold heading rendered as h2
dialog.descriptionnamenilMuted supporting text rendered as p