Sheet
Extends the Dialog component to display content that complements the main content of the screen.
Usage
Example
Sheet do
SheetTrigger do
Button(variant: :outline) { "Open" }
end
SheetContent do
SheetHeader do
SheetTitle { "Edit profile" }
SheetDescription { "Make changes to your profile here. Click save when you're done." }
end
SheetMiddle(class: "grid gap-6") do
div(class: "grid gap-3") do
FormFieldLabel(for: "sheet-demo-name") { "Name" }
Input(id: "sheet-demo-name", value: "Joel Drapper")
end
div(class: "grid gap-3") do
FormFieldLabel(for: "sheet-demo-email") { "Email" }
Input(id: "sheet-demo-email", value: "[email protected]")
end
end
SheetFooter do
Button { "Save changes" }
Button(variant: :outline, data: { action: 'click->ruby-ui--sheet#dismiss' }) { "Close" }
end
end
end
Side
Use the side keyword to choose the edge of the screen the sheet slides in from: :top, :right (default), :bottom or :left.
div(class: 'flex flex-wrap gap-2') do
[:top, :right, :bottom, :left].each do |side|
Sheet do
SheetTrigger do
Button(variant: :outline, class: 'capitalize') { side.to_s }
end
SheetContent(side: side) do
SheetHeader do
SheetTitle { "Edit profile" }
SheetDescription { "Make changes to your profile here. Click save when you're done." }
end
SheetMiddle do
p(class: 'text-sm text-muted-foreground') { "The sheet slides in from the #{side}." }
end
SheetFooter do
Button { "Save changes" }
Button(variant: :outline, data: { action: 'click->ruby-ui--sheet#dismiss' }) { "Cancel" }
end
end
end
end
end
Installation
Using RubyUI CLI
Run the install command
rails g ruby_ui:component Sheet
Manual installation
1
Add RubyUI::Sheet to app/components/ruby_ui/sheet/sheet.rb
# frozen_string_literal: true
module RubyUI
class Sheet < Base
def initialize(open: false, **attrs)
@open = open
super(**attrs)
end
def view_template(&)
div(**attrs, &)
end
private
# `contents` keeps the Stimulus wrapper out of layout, like shadcn's
# element-less Root.
def default_attrs
{
data: {
slot: "sheet",
controller: "ruby-ui--sheet",
ruby_ui__sheet_open_value: @open.to_s
},
class: "contents"
}
end
end
end
2
Add RubyUI::SheetContent to app/components/ruby_ui/sheet/sheet_content.rb
# frozen_string_literal: true
module RubyUI
# The sheet panel: a native <dialog> opened with showModal(), so the browser
# makes the page behind inert and handles Esc and initial focus. Styled after
# shadcn's SheetContent (Base UI + vega).
#
# Side classes are plain utilities (not data-[side=…] variants) so a caller's
# width or padding replaces them through tailwind_merge.
class SheetContent < Base
SIDE_CLASS = {
top: "inset-x-0 top-0 bottom-auto h-auto w-full border-b " \
"data-[state=open]:slide-in-from-top-10 data-[state=closed]:slide-out-to-top-10",
right: "inset-y-0 right-0 left-auto h-full w-3/4 border-l sm:max-w-sm " \
"data-[state=open]:slide-in-from-right-10 data-[state=closed]:slide-out-to-right-10",
bottom: "inset-x-0 bottom-0 top-auto h-auto w-full border-t " \
"data-[state=open]:slide-in-from-bottom-10 data-[state=closed]:slide-out-to-bottom-10",
left: "inset-y-0 left-0 right-auto h-full w-3/4 border-r sm:max-w-sm " \
"data-[state=open]:slide-in-from-left-10 data-[state=closed]:slide-out-to-left-10"
}.freeze
def initialize(side: :right, show_close_button: true, **attrs)
@side = SIDE_CLASS.key?(side.to_s.to_sym) ? side.to_s.to_sym : :right
@show_close_button = show_close_button
super(**attrs)
end
def view_template
dialog(**attrs) do
yield if block_given?
close_button if @show_close_button
end
end
private
def default_attrs
{
data: {
slot: "sheet-content",
side: @side,
ruby_ui__sheet_target: "content",
# Only for markup that still calls ruby-ui--sheet-content#close.
controller: "ruby-ui--sheet-content",
action: "pointerdown->ruby-ui--sheet#backdropPointerdown click->ruby-ui--sheet#backdropClick"
},
class: [
# <dialog> UA styles: fixed inset 0, max width/height and fit-content size.
"fixed z-50 m-0 max-h-none max-w-none flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg outline-none open:flex not-open:hidden",
"backdrop:bg-black/10 supports-backdrop-filter:backdrop:backdrop-blur-xs",
"duration-200 ease-in-out data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:fill-mode-forwards motion-reduce:animate-none!",
"backdrop:duration-150 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!",
SIDE_CLASS.fetch(@side)
]
}
end
# shadcn renders <Button variant="ghost" size="icon-sm">; the classes are
# inlined because Button's own data-slot would be mixed into sheet-close.
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 text-sm font-medium 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: {slot: "sheet-close", action: "click->ruby-ui--sheet#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",
aria_hidden: "true"
) 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::SheetDescription to app/components/ruby_ui/sheet/sheet_description.rb
# frozen_string_literal: true
module RubyUI
class SheetDescription < Base
def view_template(&)
p(**attrs, &)
end
private
def default_attrs
{
data: {slot: "sheet-description", ruby_ui__sheet_target: "description"},
class: "text-sm text-muted-foreground"
}
end
end
end
4
Add RubyUI::SheetDocs to app/components/ruby_ui/sheet/sheet_docs.rb
# frozen_string_literal: true
class Views::Docs::Sheet < Views::Base
def view_template
component = "Sheet"
div(class: "max-w-2xl mx-auto w-full py-10 space-y-10") do
render Docs::Header.new(title: "Sheet", description: "Extends the Dialog component to display content that complements the main content of the screen.")
Heading(level: 2) { "Usage" }
render Docs::VisualCodeExample.new(title: "Example", context: self) do
<<~RUBY
Sheet do
SheetTrigger do
Button(variant: :outline) { "Open" }
end
SheetContent do
SheetHeader do
SheetTitle { "Edit profile" }
SheetDescription { "Make changes to your profile here. Click save when you're done." }
end
SheetMiddle(class: "grid gap-6") do
div(class: "grid gap-3") do
FormFieldLabel(for: "sheet-demo-name") { "Name" }
Input(id: "sheet-demo-name", value: "Joel Drapper")
end
div(class: "grid gap-3") do
FormFieldLabel(for: "sheet-demo-email") { "Email" }
Input(id: "sheet-demo-email", value: "[email protected]")
end
end
SheetFooter do
Button { "Save changes" }
Button(variant: :outline, data: { action: 'click->ruby-ui--sheet#dismiss' }) { "Close" }
end
end
end
RUBY
end
render Docs::VisualCodeExample.new(title: "Side", description: "Use the side keyword to choose the edge of the screen the sheet slides in from: :top, :right (default), :bottom or :left.", context: self) do
<<~RUBY
div(class: 'flex flex-wrap gap-2') do
[:top, :right, :bottom, :left].each do |side|
Sheet do
SheetTrigger do
Button(variant: :outline, class: 'capitalize') { side.to_s }
end
SheetContent(side: side) do
SheetHeader do
SheetTitle { "Edit profile" }
SheetDescription { "Make changes to your profile here. Click save when you're done." }
end
SheetMiddle do
p(class: 'text-sm text-muted-foreground') { "The sheet slides in from the \#{side}." }
end
SheetFooter do
Button { "Save changes" }
Button(variant: :outline, data: { action: 'click->ruby-ui--sheet#dismiss' }) { "Cancel" }
end
end
end
end
end
RUBY
end
render Docs::VisualCodeExample.new(title: "No close button", description: "Pass show_close_button: false to hide the close button in the corner. Esc and a click outside still close the sheet.", context: self) do
<<~RUBY
Sheet do
SheetTrigger do
Button(variant: :outline) { "Open Sheet" }
end
SheetContent(show_close_button: false) do
SheetHeader do
SheetTitle { "No close button" }
SheetDescription { "This sheet doesn't have a close button in the top-right corner. Press Esc or click outside to close it." }
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::SheetFooter to app/components/ruby_ui/sheet/sheet_footer.rb
# frozen_string_literal: true
module RubyUI
class SheetFooter < Base
def view_template(&)
div(**attrs, &)
end
private
def default_attrs
{
data: {slot: "sheet-footer"},
class: "mt-auto flex flex-col gap-2 p-4"
}
end
end
end
6
Add RubyUI::SheetHeader to app/components/ruby_ui/sheet/sheet_header.rb
# frozen_string_literal: true
module RubyUI
class SheetHeader < Base
def view_template(&)
div(**attrs, &)
end
private
def default_attrs
{
data: {slot: "sheet-header"},
class: "flex flex-col gap-1.5 p-4"
}
end
end
end
7
Add RubyUI::SheetMiddle to app/components/ruby_ui/sheet/sheet_middle.rb
# frozen_string_literal: true
module RubyUI
# Body between header and footer. shadcn has no part for it; its examples
# wrap the body in <div className="px-4">, which this mirrors.
class SheetMiddle < Base
def view_template(&)
div(**attrs, &)
end
private
def default_attrs
{
class: "px-4"
}
end
end
end
8
Add RubyUI::SheetTitle to app/components/ruby_ui/sheet/sheet_title.rb
# frozen_string_literal: true
module RubyUI
class SheetTitle < Base
def view_template(&)
h2(**attrs, &)
end
private
def default_attrs
{
data: {slot: "sheet-title", ruby_ui__sheet_target: "title"},
class: "font-heading font-medium text-foreground"
}
end
end
end
9
Add RubyUI::SheetTrigger to app/components/ruby_ui/sheet/sheet_trigger.rb
# frozen_string_literal: true
module RubyUI
# Wraps the element that opens the sheet. The controller puts
# aria-haspopup/aria-expanded/aria-controls on the button or link inside.
class SheetTrigger < Base
def view_template(&)
div(**attrs, &)
end
private
def default_attrs
{
data: {
slot: "sheet-trigger",
ruby_ui__sheet_target: "trigger",
action: "click->ruby-ui--sheet#open"
},
class: "inline-block"
}
end
end
end
10
Add sheet_content_controller.js to app/javascript/controllers/ruby_ui/sheet_content_controller.js
import { Controller } from "@hotwired/stimulus"
// Connects to data-controller="ruby-ui--sheet-content"
// Kept for markup written against the old Sheet: `click->ruby-ui--sheet-content#close`
// still closes the sheet. New code uses `click->ruby-ui--sheet#dismiss`.
export default class extends Controller {
close() {
const root = this.element.closest('[data-controller~="ruby-ui--sheet"]')
const sheet = root && this.application.getControllerForElementAndIdentifier(root, "ruby-ui--sheet")
sheet?.dismiss()
}
}
11
Add sheet_controller.js to app/javascript/controllers/ruby_ui/sheet_controller.js
import { Controller } from "@hotwired/stimulus"
// Prefix for generated ids, unique per page (ruby-ui-sheet-1-title, …).
let sheetCount = 0
// Connects to data-controller="ruby-ui--sheet"
// The panel is a native <dialog> opened with showModal(). Exit, Tab loop and
// ARIA wiring match RubyUI's Dialog controller; the Turbo and scroll-lock
// lifecycle matches AlertDialog's.
export default class extends Controller {
static targets = ["content", "title", "description", "trigger"]
static values = {
open: {
type: Boolean,
default: false
},
}
// Target callbacks (they run before connect) also cover Turbo replacing only
// the panel while the Sheet root stays.
contentTargetConnected(content) {
// A Turbo cache snapshot keeps `open` but not modality; don't restore it half-open.
if (content.open && !content.matches(":modal")) content.close()
content.addEventListener("close", this.handleClose)
content.addEventListener("cancel", this.handleCancel)
content.addEventListener("keydown", this.handleKeydown)
this.wireAria()
}
contentTargetDisconnected(content) {
content.removeEventListener("close", this.handleClose)
content.removeEventListener("cancel", this.handleCancel)
content.removeEventListener("keydown", this.handleKeydown)
this.closeToken = null
// Drop aria-expanded/aria-controls pointing at the panel that just left.
this.setTriggersExpanded(false)
this.releaseScroll()
}
connect() {
this.closeToken = null
document.addEventListener("turbo:before-cache", this.handleBeforeCache)
if (this.openValue) {
this.open()
}
}
disconnect() {
document.removeEventListener("turbo:before-cache", this.handleBeforeCache)
this.closeToken = null
this.releaseScroll()
}
open(e) {
e?.preventDefault()
if (!this.hasContentTarget) return
// Reopening during the exit animation invalidates the pending close.
this.closeToken = null
this.contentTarget.dataset.state = "open"
if (!this.contentTarget.open) this.contentTarget.showModal()
this.scrollLocked = true
document.body.classList.add("overflow-hidden")
this.setTriggersExpanded(true)
}
// Turbo snapshots the page right after this event and `close` fires later,
// so clean up synchronously: the cache never keeps an open sheet or a locked body.
handleBeforeCache = () => {
if (!this.hasContentTarget || !this.contentTarget.open) return
this.closeToken = null
this.openValue = false
this.contentTarget.close()
delete this.contentTarget.dataset.state
this.releaseScroll()
this.setTriggersExpanded(false)
}
// Only release a lock this sheet took, and keep it while another modal is open.
releaseScroll() {
if (!this.scrollLocked) return
this.scrollLocked = false
if (!document.querySelector("dialog:modal")) document.body.classList.remove("overflow-hidden")
}
dismiss() {
this.animateClose()
}
// Plays the exit animation on the panel and its ::backdrop, then closes.
async animateClose() {
if (this.closeToken || !this.hasContentTarget || !this.contentTarget.open) return
// Each close gets its own token: a reopen, a later close or a native close
// replaces it, so a stale animation promise never closes a newer open.
const token = (this.closeToken = Symbol("close"))
this.contentTarget.dataset.state = "closed"
await Promise.allSettled(this.ownAnimations().map((animation) => animation.finished))
if (this.closeToken === token) this.contentTarget.close()
}
// Only the dialog's own animations (panel + ::backdrop), never its descendants'.
ownAnimations() {
return document.getAnimations().filter((animation) => animation.effect?.target === this.contentTarget)
}
// 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.contentTarget && this.isOutsidePanel(e)
}
backdropClick(e) {
const startedOutside = this.pressStartedOutside
this.pressStartedOutside = false
if (e.target !== this.contentTarget) return
if (startedOutside && this.isOutsidePanel(e)) {
this.dismiss()
}
}
isOutsidePanel(e) {
const rect = this.contentTarget.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.contentTarget) return
e.preventDefault()
this.animateClose()
}
// Native <dialog> lets Tab leave the last control for the browser UI before it
// wraps. Keep focus cycling inside, as the APG dialog pattern asks.
handleKeydown = (e) => {
if (e.key !== "Tab" || e.defaultPrevented || e.altKey || e.ctrlKey || e.metaKey) return
// A nested dialog handles its own Tab.
if (e.target.closest("dialog") !== this.contentTarget) return
const tabbables = this.tabbables()
if (tabbables.length === 0) {
e.preventDefault()
return
}
const first = tabbables[0]
const last = tabbables[tabbables.length - 1]
const active = document.activeElement
if (e.shiftKey && (active === first || active === this.contentTarget)) {
e.preventDefault()
last.focus()
} else if (!e.shiftKey && active === last) {
e.preventDefault()
first.focus()
}
}
// Candidates in the browser's sequential focus order: positive tabindex first,
// ascending, then tabindex 0 in DOM order (Array#sort is stable).
tabbables() {
const selector = "a[href], area[href], button, input, select, textarea, iframe, object, embed, summary, audio[controls], video[controls], [contenteditable], [tabindex]"
return [...this.contentTarget.querySelectorAll(selector)]
.filter((el) =>
this.inTabOrder(el) &&
!el.matches(":disabled") && // also covers controls inside fieldset[disabled]
!el.closest("[inert]") &&
el.closest("dialog") === this.contentTarget &&
this.isVisible(el)
)
.sort((a, b) => {
const ta = Math.max(a.tabIndex, 0)
const tb = Math.max(b.tabIndex, 0)
if (ta === tb) return 0
if (ta === 0) return 1
if (tb === 0) return -1
return ta - tb
})
}
// An explicit tabindex decides; otherwise the element is natively focusable.
// `tabIndex` reads -1 for an editing host and (in Chromium and WebKit) for
// <embed>, yet Tab reaches both. A contenteditable inside another is skipped.
inTabOrder(el) {
if (el.hasAttribute("tabindex")) return el.tabIndex >= 0
if (el.isContentEditable) return !el.parentElement?.isContentEditable
return el.localName === "embed" || el.tabIndex >= 0
}
// checkVisibility() landed in Safari 17.4; older engines fall back to layout boxes.
isVisible(el) {
if (typeof el.checkVisibility === "function") return el.checkVisibility({ visibilityProperty: true })
return el.getClientRects().length > 0
}
handleClose = (e) => {
this.closeToken = null
// A cached snapshot must not carry open-value="true" and reopen on restore.
this.openValue = false
delete e.currentTarget.dataset.state
this.releaseScroll()
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.contentTarget
const prefix = `ruby-ui-sheet-${++sheetCount}`
const contentId = 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.contentId = contentId
this.triggerTargets.forEach((trigger) => this.wireTrigger(trigger))
}
// Also wires triggers added after the panel connected.
triggerTargetConnected(trigger) {
this.wireTrigger(trigger)
}
wireTrigger(trigger) {
const control = this.triggerControl(trigger)
if (!control) return
if (!control.hasAttribute("aria-haspopup")) control.setAttribute("aria-haspopup", "dialog")
// Before the panel connects there is nothing to point at yet; wireAria comes back.
if (this.hasContentTarget && this.contentId) this.setExpanded(control, this.contentTarget.open)
}
// First target that lives inside this controller's own <dialog>.
ownTarget(targets) {
return targets.find((target) => this.contentTarget.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 climbs above the Sheet root, as Dialog's
// does, so a menu item that wraps the Sheet carries the ARIA.
triggerControl(trigger) {
return trigger.querySelector("button, a[href], [role=button]") || trigger.closest("[role=menuitem], [role=button]")
}
triggerControls() {
return this.triggerTargets.map((trigger) => this.triggerControl(trigger)).filter(Boolean)
}
// aria-controls is set only while open, as Base UI does. On close only a value
// pointing at this dialog is removed; one pointing elsewhere is the author's and
// stays. No state is kept, so a trigger added later or a reconnect (Turbo cache)
// behaves the same.
setTriggersExpanded(expanded) {
this.triggerControls().forEach((control) => this.setExpanded(control, expanded))
}
setExpanded(control, expanded) {
control.setAttribute("aria-expanded", String(expanded))
if (expanded && !control.hasAttribute("aria-controls")) {
control.setAttribute("aria-controls", this.contentId)
} else if (!expanded && control.getAttribute("aria-controls") === this.contentId) {
control.removeAttribute("aria-controls")
}
}
}
12
Update the Stimulus controllers manifest file
rake stimulus:manifest:updateComponents
| Component | Built using | Source |
|---|---|---|
Sheet | Phlex | |
SheetContent | Phlex | |
SheetDescription | Phlex | |
SheetDocs | Phlex | |
SheetFooter | Phlex | |
SheetHeader | Phlex | |
SheetMiddle | Phlex | |
SheetTitle | Phlex | |
SheetTrigger | Phlex | |
SheetContentController | Stimulus JS | |
SheetController | Stimulus JS |