Components
Sheet
A draggable dialog with configurable heights.
Height: fit
<div data-controller="demo">
<div class="flex w-full flex-col items-center gap-4" data-controller="confirm-dialog">
<button type="button" class="<%= button_class(variant: :secondary) %>" data-action="confirm-dialog#open">Open sheet</button>
<%= tag.dialog id: "example-0-dialog", class: dialog_class, data: sheet_data({ confirm_dialog_target: "dialog" }, detents: "fit"), aria: { labelledby: "example-0-title" } do %>
<div class="flex flex-col gap-4 pt-6 pb-5 sm:pt-5">
<h2 id="example-0-title" class="px-5 text-lg font-semibold">New project</h2>
<div class="@container mx-4 flex flex-col gap-0.5 overflow-hidden rounded-2xl">
<label class="<%= sheet_row_class %>">
<span class="<%= sheet_row_label_class %>"><%= heroicon "pencil", options: { "aria-hidden": "true" } %><span>Title</span></span>
<input class="<%= sheet_text_input_class %>" placeholder="Website redesign">
</label>
<div class="<%= sheet_row_class %>">
<span class="<%= sheet_row_label_class %>"><%= heroicon "calendar", options: { "aria-hidden": "true" } %><span>When</span></span>
<input type="datetime-local" class="<%= sheet_datetime_input_class %>" value="2026-11-03T19:30" aria-label="When">
</div>
<div class="<%= sheet_row_class %>">
<span class="<%= sheet_row_label_class %>"><%= heroicon "tag", options: { "aria-hidden": "true" } %><span>Category</span></span>
<div class="flex flex-wrap gap-2">
<a class="<%= sheet_chip_class(active: true) %>" href="#main">Design</a>
<button type="button" class="<%= sheet_chip_class %>" aria-pressed="false" data-action="demo#press">Product</button>
</div>
</div>
<div class="<%= sheet_row_class %>">
<span class="<%= sheet_row_label_class %>"><%= heroicon "users", options: { "aria-hidden": "true" } %><span>Owner</span></span>
<div class="flex flex-wrap gap-2">
<% { "Alex" => true, "Sam" => false }.each do |name, pressed| %>
<button type="button" class="<%= sheet_chip_class(member: true) %>" aria-pressed="<%= pressed %>" data-action="demo#press">
<span class="<%= avatar_class(size: :sm) %>" style="background-color: var(--<%= pressed ? "sky" : "leaf" %>)"><%= name.first %></span><%= name %>
</button>
<% end %>
</div>
</div>
</div>
<div class="px-5"><button type="button" class="<%= button_class(variant: :accent, block: true) %>" data-action="confirm-dialog#close">Create project</button></div>
</div>
<% end %>
</div>
</div>
Interaction controller
app/javascript/controllers/demo_controller.jsimport { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["toast", "toasts"]
press({ currentTarget }) {
currentTarget.setAttribute(
"aria-pressed",
String(currentTarget.getAttribute("aria-pressed") !== "true"),
)
}
select({ currentTarget }) {
for (const tab of currentTarget.closest("[role=tablist]").querySelectorAll("[role=tab]")) {
tab.setAttribute("aria-selected", String(tab === currentTarget))
}
}
choose({ currentTarget }) {
for (const button of currentTarget.closest("[role=group]").querySelectorAll("button")) {
button.setAttribute("aria-pressed", String(button === currentTarget))
}
}
toast() {
this.toastsTarget.append(this.toastTarget.content.cloneNode(true))
}
}
Height: medium large
<div data-controller="demo">
<div class="flex w-full flex-col items-center gap-4" data-controller="confirm-dialog">
<button type="button" class="<%= button_class(variant: :secondary) %>" data-action="confirm-dialog#open">Open sheet</button>
<%= tag.dialog id: "example-1-dialog", class: dialog_class, data: sheet_data({ confirm_dialog_target: "dialog" }, detents: "medium large"), aria: { labelledby: "example-1-title" } do %>
<div class="flex flex-col gap-4 pt-6 pb-5 sm:pt-5">
<h2 id="example-1-title" class="px-5 text-lg font-semibold">New project</h2>
<div class="@container mx-4 flex flex-col gap-0.5 overflow-hidden rounded-2xl">
<label class="<%= sheet_row_class %>">
<span class="<%= sheet_row_label_class %>"><%= heroicon "pencil", options: { "aria-hidden": "true" } %><span>Title</span></span>
<input class="<%= sheet_text_input_class %>" placeholder="Website redesign">
</label>
<div class="<%= sheet_row_class %>">
<span class="<%= sheet_row_label_class %>"><%= heroicon "calendar", options: { "aria-hidden": "true" } %><span>When</span></span>
<input type="datetime-local" class="<%= sheet_datetime_input_class %>" value="2026-11-03T19:30" aria-label="When">
</div>
<div class="<%= sheet_row_class %>">
<span class="<%= sheet_row_label_class %>"><%= heroicon "tag", options: { "aria-hidden": "true" } %><span>Category</span></span>
<div class="flex flex-wrap gap-2">
<a class="<%= sheet_chip_class(active: true) %>" href="#main">Design</a>
<button type="button" class="<%= sheet_chip_class %>" aria-pressed="false" data-action="demo#press">Product</button>
</div>
</div>
<div class="<%= sheet_row_class %>">
<span class="<%= sheet_row_label_class %>"><%= heroicon "users", options: { "aria-hidden": "true" } %><span>Owner</span></span>
<div class="flex flex-wrap gap-2">
<% { "Alex" => true, "Sam" => false }.each do |name, pressed| %>
<button type="button" class="<%= sheet_chip_class(member: true) %>" aria-pressed="<%= pressed %>" data-action="demo#press">
<span class="<%= avatar_class(size: :sm) %>" style="background-color: var(--<%= pressed ? "sky" : "leaf" %>)"><%= name.first %></span><%= name %>
</button>
<% end %>
</div>
</div>
</div>
<div class="px-5"><button type="button" class="<%= button_class(variant: :accent, block: true) %>" data-action="confirm-dialog#close">Create project</button></div>
</div>
<% end %>
</div>
</div>
Interaction controller
app/javascript/controllers/demo_controller.jsimport { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["toast", "toasts"]
press({ currentTarget }) {
currentTarget.setAttribute(
"aria-pressed",
String(currentTarget.getAttribute("aria-pressed") !== "true"),
)
}
select({ currentTarget }) {
for (const tab of currentTarget.closest("[role=tablist]").querySelectorAll("[role=tab]")) {
tab.setAttribute("aria-selected", String(tab === currentTarget))
}
}
choose({ currentTarget }) {
for (const button of currentTarget.closest("[role=group]").querySelectorAll("button")) {
button.setAttribute("aria-pressed", String(button === currentTarget))
}
}
toast() {
this.toastsTarget.append(this.toastTarget.content.cloneNode(true))
}
}
Height: large
<div data-controller="demo">
<div class="flex w-full flex-col items-center gap-4" data-controller="confirm-dialog">
<button type="button" class="<%= button_class(variant: :secondary) %>" data-action="confirm-dialog#open">Open sheet</button>
<%= tag.dialog id: "example-2-dialog", class: dialog_class, data: sheet_data({ confirm_dialog_target: "dialog" }, detents: "large"), aria: { labelledby: "example-2-title" } do %>
<div class="flex flex-col gap-4 pt-6 pb-5 sm:pt-5">
<h2 id="example-2-title" class="px-5 text-lg font-semibold">New project</h2>
<div class="@container mx-4 flex flex-col gap-0.5 overflow-hidden rounded-2xl">
<label class="<%= sheet_row_class %>">
<span class="<%= sheet_row_label_class %>"><%= heroicon "pencil", options: { "aria-hidden": "true" } %><span>Title</span></span>
<input class="<%= sheet_text_input_class %>" placeholder="Website redesign">
</label>
<div class="<%= sheet_row_class %>">
<span class="<%= sheet_row_label_class %>"><%= heroicon "calendar", options: { "aria-hidden": "true" } %><span>When</span></span>
<input type="datetime-local" class="<%= sheet_datetime_input_class %>" value="2026-11-03T19:30" aria-label="When">
</div>
<div class="<%= sheet_row_class %>">
<span class="<%= sheet_row_label_class %>"><%= heroicon "tag", options: { "aria-hidden": "true" } %><span>Category</span></span>
<div class="flex flex-wrap gap-2">
<a class="<%= sheet_chip_class(active: true) %>" href="#main">Design</a>
<button type="button" class="<%= sheet_chip_class %>" aria-pressed="false" data-action="demo#press">Product</button>
</div>
</div>
<div class="<%= sheet_row_class %>">
<span class="<%= sheet_row_label_class %>"><%= heroicon "users", options: { "aria-hidden": "true" } %><span>Owner</span></span>
<div class="flex flex-wrap gap-2">
<% { "Alex" => true, "Sam" => false }.each do |name, pressed| %>
<button type="button" class="<%= sheet_chip_class(member: true) %>" aria-pressed="<%= pressed %>" data-action="demo#press">
<span class="<%= avatar_class(size: :sm) %>" style="background-color: var(--<%= pressed ? "sky" : "leaf" %>)"><%= name.first %></span><%= name %>
</button>
<% end %>
</div>
</div>
</div>
<div class="px-5"><button type="button" class="<%= button_class(variant: :accent, block: true) %>" data-action="confirm-dialog#close">Create project</button></div>
</div>
<% end %>
</div>
</div>
Interaction controller
app/javascript/controllers/demo_controller.jsimport { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["toast", "toasts"]
press({ currentTarget }) {
currentTarget.setAttribute(
"aria-pressed",
String(currentTarget.getAttribute("aria-pressed") !== "true"),
)
}
select({ currentTarget }) {
for (const tab of currentTarget.closest("[role=tablist]").querySelectorAll("[role=tab]")) {
tab.setAttribute("aria-selected", String(tab === currentTarget))
}
}
choose({ currentTarget }) {
for (const button of currentTarget.closest("[role=group]").querySelectorAll("button")) {
button.setAttribute("aria-pressed", String(button === currentTarget))
}
}
toast() {
this.toastsTarget.append(this.toastTarget.content.cloneNode(true))
}
}
API reference
avatar_class
avatar_class(size: :base, ink: false, linked: false, extra: nil)
| Parameter | Default | Values |
|---|---|---|
| size | :base | :base · :lg · :sm · :xl |
| ink | false | true · false |
| linked | false | true · false |
| extra | nil | Additional Tailwind classes |
button_class
button_class(variant: :default, size: :base, block: false, extra: nil)
| Parameter | Default | Values |
|---|---|---|
| variant | :default | :default · :accent · :secondary · :glass · :ghost · :danger |
| size | :base | :sm · :base · :lg · :nav |
| block | false | true · false |
| extra | nil | Additional Tailwind classes |
dialog_class
dialog_class(kind = :modal, extra: nil)
| Parameter | Default | Values |
|---|---|---|
| kind | :modal | :modal · :context |
| extra | nil | Additional Tailwind classes |
sheet_data
sheet_data(data = {}, detents: "fit")
| Parameter | Default | Values |
|---|---|---|
| data | {} | — |
| detents | "fit" | "fit" · "medium large" · "large" |
sheet_row_class
sheet_row_class(extra: nil)
| Parameter | Default | Values |
|---|---|---|
| extra | nil | Additional Tailwind classes |
sheet_row_label_class
sheet_row_label_class(extra: nil)
| Parameter | Default | Values |
|---|---|---|
| extra | nil | Additional Tailwind classes |
sheet_chip_class
sheet_chip_class(active: false, member: false, extra: nil)
| Parameter | Default | Values |
|---|---|---|
| active | false | true · false |
| member | false | true · false |
| extra | nil | Additional Tailwind classes |
sheet_datetime_input_class
sheet_datetime_input_class(extra: nil)
| Parameter | Default | Values |
|---|---|---|
| extra | nil | Additional Tailwind classes |
sheet_text_input_class
sheet_text_input_class(extra: nil)
| Parameter | Default | Values |
|---|---|---|
| extra | nil | Additional Tailwind classes |
Controller options
| Attribute | Type | Default |
|---|---|---|
| data-sheet-detents-value | String | "fit" |