Dialog
A window overlaid on either the primary window or another dialog window, rendering the content underneath inert.
Usage
Example
Dialog do DialogTrigger do Button { "Open Dialog" } end DialogContent do DialogHeader do DialogTitle { "RubyUI to the rescue" } DialogDescription { "RubyUI helps you build accessible standard compliant web apps with ease" } end DialogMiddle do AspectRatio(aspect_ratio: "16/9", class: 'rounded-md overflow-hidden border') do img( alt: "Placeholder", loading: "lazy", src: image_path("pattern.jpg") ) end end DialogFooter do Button(variant: :outline, data: { action: 'click->ruby-ui--dialog#dismiss' }) { "Cancel" } Button { "Save" } end end end
Size
Without a size, the dialog fills the viewport width up to 28rem (max-w-md) on wider screens. The sizes below apply on wider screens.
div(class: 'flex flex-wrap justify-center gap-2') do Dialog do DialogTrigger do Button { "Small Dialog" } end DialogContent(size: :sm) do DialogHeader do DialogTitle { "RubyUI to the rescue" } DialogDescription { "RubyUI helps you build accessible standard compliant web apps with ease" } end DialogMiddle do AspectRatio(aspect_ratio: "16/9", class: 'rounded-md overflow-hidden border') do img( alt: "Placeholder", loading: "lazy", src: image_path("pattern.jpg") ) end end DialogFooter do Button(variant: :outline, data: { action: 'click->ruby-ui--dialog#dismiss' }) { "Cancel" } Button { "Save" } end end end Dialog do DialogTrigger do Button { "Large Dialog" } end DialogContent(size: :lg) do DialogHeader do DialogTitle { "RubyUI to the rescue" } DialogDescription { "RubyUI helps you build accessible standard compliant web apps with ease" } end DialogMiddle do AspectRatio(aspect_ratio: "16/9", class: 'rounded-md overflow-hidden border') do img( alt: "Placeholder", loading: "lazy", src: image_path("pattern.jpg") ) end end DialogFooter do Button(variant: :outline, data: { action: 'click->ruby-ui--dialog#dismiss' }) { "Cancel" } Button { "Save" } end end end end
Close button
DialogContent shows a close button in the corner by default. Pass show_close_button: false to hide it, and show_close_button: true on DialogFooter to add a Close button to the footer.
Dialog do DialogTrigger do Button { "Open Dialog" } end DialogContent(show_close_button: false) do DialogHeader do DialogTitle { "RubyUI to the rescue" } DialogDescription { "RubyUI helps you build accessible standard compliant web apps with ease" } end DialogFooter(show_close_button: true) do Button { "Save" } end end end
Installation
Using RubyUI CLI
Run the install command
rails g ruby_ui:component Dialog
Manual installation
1
Add RubyUI::Dialog to app/components/ruby_ui/dialog/dialog.rb
# frozen_string_literal: true module RubyUI class Dialog < Base def initialize(open: false, **attrs) @open = open super(**attrs) end def view_template(&) div(**attrs, &) end private def default_attrs { data: { controller: "ruby-ui--dialog", ruby_ui__dialog_open_value: @open } } end end end
2
Add RubyUI::DialogContent to app/components/ruby_ui/dialog/dialog_content.rb
# frozen_string_literal: true module RubyUI class DialogContent < Base SIZES = { xs: "max-w-sm", sm: "max-w-md", md: "max-w-lg", lg: "max-w-2xl", xl: "max-w-4xl", full: "max-w-full" } DEFAULT_WIDTH = "max-w-[calc(100%-2rem)] sm:max-w-md" def initialize(size: nil, show_close_button: true, **attrs) @size = size @show_close_button = show_close_button super(**attrs) end def view_template dialog(**attrs) do yield close_button if @show_close_button end end private def default_attrs { data_ruby_ui__dialog_target: "dialog", data_action: "pointerdown->ruby-ui--dialog#backdropPointerdown click->ruby-ui--dialog#backdropClick", class: [ "fixed open:grid top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-50 w-full gap-6 rounded-xl bg-popover p-6 text-sm text-popover-foreground outline-none ring-1 ring-foreground/10 backdrop:bg-black/10 supports-backdrop-filter:backdrop:backdrop-blur-xs", "duration-100 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=closed]:fill-mode-forwards motion-reduce:animate-none!", "backdrop:duration-100 data-[state=open]:backdrop:animate-in data-[state=open]:backdrop:fade-in-0 data-[state=closed]:backdrop:animate-out data-[state=closed]:backdrop:fade-out-0 data-[state=closed]:backdrop:fill-mode-forwards motion-reduce:backdrop:animate-none!", SIZES.fetch(@size, DEFAULT_WIDTH) ] } end def close_button button( type: "button", class: "absolute top-4 end-4 size-8 shrink-0 rounded-[min(var(--radius-md),10px)] inline-flex items-center justify-center border border-transparent bg-clip-padding whitespace-nowrap outline-none select-none transition-all hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg]:size-4", data_action: "click->ruby-ui--dialog#dismiss" ) do svg( xmlns: "http://www.w3.org/2000/svg", viewbox: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round" ) do |s| s.path(d: "M18 6 6 18") s.path(d: "m6 6 12 12") end span(class: "sr-only") { "Close" } end end end end
3
Add RubyUI::DialogDescription to app/components/ruby_ui/dialog/dialog_description.rb
# frozen_string_literal: true module RubyUI class DialogDescription < Base def view_template(&) p(**attrs, &) end private def default_attrs { class: "text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground", data_ruby_ui__dialog_target: "description" } end end end
4
Add RubyUI::DialogDocs to app/components/ruby_ui/dialog/dialog_docs.rb
# frozen_string_literal: true class Views::Docs::Dialog < Views::Base def view_template component = "Dialog" div(class: "max-w-2xl mx-auto w-full py-10 space-y-10") do render Docs::Header.new(title: "Dialog", description: "A window overlaid on either the primary window or another dialog window, rendering the content underneath inert.") Heading(level: 2) { "Usage" } render Docs::VisualCodeExample.new(title: "Example", context: self) do <<~RUBY Dialog do DialogTrigger do Button { "Open Dialog" } end DialogContent do DialogHeader do DialogTitle { "RubyUI to the rescue" } DialogDescription { "RubyUI helps you build accessible standard compliant web apps with ease" } end DialogMiddle do AspectRatio(aspect_ratio: "16/9", class: 'rounded-md overflow-hidden border') do img( alt: "Placeholder", loading: "lazy", src: image_path("pattern.jpg") ) end end DialogFooter do Button(variant: :outline, data: { action: 'click->ruby-ui--dialog#dismiss' }) { "Cancel" } Button { "Save" } end end end RUBY end render Docs::VisualCodeExample.new(title: "Size", description: "Without a size, the dialog fills the viewport width up to 28rem (max-w-md) on wider screens. The sizes below apply on wider screens.", context: self) do <<~RUBY div(class: 'flex flex-wrap justify-center gap-2') do Dialog do DialogTrigger do Button { "Small Dialog" } end DialogContent(size: :sm) do DialogHeader do DialogTitle { "RubyUI to the rescue" } DialogDescription { "RubyUI helps you build accessible standard compliant web apps with ease" } end DialogMiddle do AspectRatio(aspect_ratio: "16/9", class: 'rounded-md overflow-hidden border') do img( alt: "Placeholder", loading: "lazy", src: image_path("pattern.jpg") ) end end DialogFooter do Button(variant: :outline, data: { action: 'click->ruby-ui--dialog#dismiss' }) { "Cancel" } Button { "Save" } end end end Dialog do DialogTrigger do Button { "Large Dialog" } end DialogContent(size: :lg) do DialogHeader do DialogTitle { "RubyUI to the rescue" } DialogDescription { "RubyUI helps you build accessible standard compliant web apps with ease" } end DialogMiddle do AspectRatio(aspect_ratio: "16/9", class: 'rounded-md overflow-hidden border') do img( alt: "Placeholder", loading: "lazy", src: image_path("pattern.jpg") ) end end DialogFooter do Button(variant: :outline, data: { action: 'click->ruby-ui--dialog#dismiss' }) { "Cancel" } Button { "Save" } end end end end RUBY end render Docs::VisualCodeExample.new(title: "Close button", description: "DialogContent shows a close button in the corner by default. Pass show_close_button: false to hide it, and show_close_button: true on DialogFooter to add a Close button to the footer.", context: self) do <<~RUBY Dialog do DialogTrigger do Button { "Open Dialog" } end DialogContent(show_close_button: false) do DialogHeader do DialogTitle { "RubyUI to the rescue" } DialogDescription { "RubyUI helps you build accessible standard compliant web apps with ease" } end DialogFooter(show_close_button: true) do Button { "Save" } end end end RUBY end render Components::ComponentSetup::Tabs.new(component_name: component) render Docs::ComponentsTable.new(component_files(component)) end end end
5
Add RubyUI::DialogFooter to app/components/ruby_ui/dialog/dialog_footer.rb
# frozen_string_literal: true module RubyUI class DialogFooter < Base def initialize(show_close_button: false, **attrs) @show_close_button = show_close_button super(**attrs) end def view_template div(**attrs) do yield if block_given? close_button if @show_close_button end end private def default_attrs { class: "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end" } end def close_button Button(variant: :outline, data: {action: "click->ruby-ui--dialog#dismiss"}) { "Close" } end end end
6
Add RubyUI::DialogHeader to app/components/ruby_ui/dialog/dialog_header.rb
# frozen_string_literal: true module RubyUI class DialogHeader < Base def view_template(&) div(**attrs, &) end private def default_attrs { class: "flex flex-col gap-2" } end end end
7
Add RubyUI::DialogMiddle to app/components/ruby_ui/dialog/dialog_middle.rb
# frozen_string_literal: true module RubyUI class DialogMiddle < Base def view_template(&) div(**attrs, &) end private def default_attrs { class: "py-4" } end end end
8
Add RubyUI::DialogTitle to app/components/ruby_ui/dialog/dialog_title.rb
# frozen_string_literal: true module RubyUI class DialogTitle < Base def view_template(&) h2(**attrs, &) end private def default_attrs { class: "font-heading leading-none font-medium", data_ruby_ui__dialog_target: "title" } end end end
9
Add RubyUI::DialogTrigger to app/components/ruby_ui/dialog/dialog_trigger.rb
# frozen_string_literal: true module RubyUI class DialogTrigger < Base def view_template(&) div(**attrs, &) end private def default_attrs { data: { ruby_ui__dialog_target: "trigger", action: "click->ruby-ui--dialog#open" }, class: "inline-block" } end end end
10
Add dialog_controller.js to app/javascript/controllers/ruby_ui/dialog_controller.js
import { Controller } from "@hotwired/stimulus" // Prefix for generated ids, unique per page (ruby-ui-dialog-1-title, …). let dialogCount = 0 // Connects to data-controller="ruby-ui--dialog" export default class extends Controller { static targets = ["dialog", "title", "description", "trigger"] static values = { open: { type: Boolean, default: false }, } connect() { this.closing = false this.dialogTarget.addEventListener("close", this.handleClose) this.dialogTarget.addEventListener("cancel", this.handleCancel) this.wireAria() if (this.openValue) { this.open() } } disconnect() { this.closing = false this.dialogTarget.removeEventListener("close", this.handleClose) this.dialogTarget.removeEventListener("cancel", this.handleCancel) document.body.classList.remove("overflow-hidden") } open(e) { e?.preventDefault() // Reopening during the exit animation must not be closed by the stale animation promise. this.closing = false this.dialogTarget.dataset.state = "open" this.dialogTarget.showModal() document.body.classList.add("overflow-hidden") this.setTriggersExpanded(true) } dismiss() { this.animateClose() } // Plays the exit animation on the panel and its ::backdrop, then closes. async animateClose() { if (this.closing || !this.dialogTarget.open) return this.closing = true this.dialogTarget.dataset.state = "closed" await Promise.allSettled(this.ownAnimations().map((animation) => animation.finished)) // handleClose/disconnect reset `closing`: the dialog already closed (e.g. a forced // second Esc) and may have been reopened since, so don't close it again. if (this.closing) this.dialogTarget.close() } // Only the dialog's own animations (panel + ::backdrop), never its descendants'. ownAnimations() { return document.getAnimations().filter((animation) => animation.effect?.target === this.dialogTarget) } // The panel's own padding also reports the <dialog> as target, so only a press // that starts and ends outside the panel's rect counts as a backdrop click. backdropPointerdown(e) { this.pressStartedOutside = e.target === this.dialogTarget && this.isOutsidePanel(e) } backdropClick(e) { const startedOutside = this.pressStartedOutside this.pressStartedOutside = false if (e.target !== this.dialogTarget) return if (startedOutside && this.isOutsidePanel(e)) { this.dismiss() } } isOutsidePanel(e) { const rect = this.dialogTarget.getBoundingClientRect() return e.clientX < rect.left || e.clientX > rect.right || e.clientY < rect.top || e.clientY > rect.bottom } // Esc fires `cancel`; hold the native close so the exit animation can play. // A dismissed <input type="file"> picker also fires a bubbling `cancel`; ignore it. handleCancel = (e) => { if (e.target !== this.dialogTarget) return e.preventDefault() this.animateClose() } handleClose = () => { this.closing = false delete this.dialogTarget.dataset.state document.body.classList.remove("overflow-hidden") this.setTriggersExpanded(false) } // Names the dialog after its title/description and ties each trigger to it. // Never overwrites ids or ARIA the author already set. wireAria() { const dialog = this.dialogTarget const prefix = `ruby-ui-dialog-${++dialogCount}` const dialogId = this.ensureId(dialog, `${prefix}-content`) const title = this.ownTarget(this.titleTargets) if (title) { const titleId = this.ensureId(title, `${prefix}-title`) if (!dialog.hasAttribute("aria-labelledby") && !dialog.hasAttribute("aria-label")) { dialog.setAttribute("aria-labelledby", titleId) } } const description = this.ownTarget(this.descriptionTargets) if (description) { const descriptionId = this.ensureId(description, `${prefix}-description`) if (!dialog.hasAttribute("aria-describedby")) { dialog.setAttribute("aria-describedby", descriptionId) } } this.triggerControls().forEach((control) => { if (!control.hasAttribute("aria-haspopup")) control.setAttribute("aria-haspopup", "dialog") if (!control.hasAttribute("aria-controls")) control.setAttribute("aria-controls", dialogId) control.setAttribute("aria-expanded", String(dialog.open)) }) } // First target that lives inside this controller's own <dialog>. ownTarget(targets) { return targets.find((target) => this.dialogTarget.contains(target)) } ensureId(element, id) { if (!element.id) element.id = id return element.id } // The element that carries the trigger's role: a button/link inside the // trigger wrapper, or the menuitem/button that wraps it. A role-less wrapper // gets no ARIA. The `closest` lookup intentionally climbs above the Dialog root // for the dropdown pattern (DropdownMenuItem wraps the Dialog, see // docs/app/views/docs/dropdown_menu.rb). triggerControls() { return this.triggerTargets .map((trigger) => trigger.querySelector("button, a[href], [role=button]") || trigger.closest("[role=menuitem], [role=button]")) .filter(Boolean) } setTriggersExpanded(expanded) { this.triggerControls().forEach((control) => control.setAttribute("aria-expanded", String(expanded))) } }
11
Update the Stimulus controllers manifest file
Importmap!
rake stimulus:manifest:update
12
Install required components
Component Dialog relies on the following RubyUI components. Refer to their individual installation guides for setup instructions:
Components
| Component | Built using | Source |
|---|---|---|
Dialog | Phlex | |
DialogContent | Phlex | |
DialogDescription | Phlex | |
DialogDocs | Phlex | |
DialogFooter | Phlex | |
DialogHeader | Phlex | |
DialogMiddle | Phlex | |
DialogTitle | Phlex | |
DialogTrigger | Phlex | |
DialogController | Stimulus JS |