Components
Sheet
An edge-anchored slide-in drawer built on the native <dialog> element. Same showModal() top-layer, focus trap, ESC and ::backdrop as Dialog — just pinned to the left, right, top or bottom edge. Light dismiss is the native closedby="any" attribute, no extra JS.
Installation
A Sheet is a <dialog data-slot="sheet">; it reuses Dialog's trigger / close wiring in public/site.js — no new script. The slide-in animation lives in your stylesheet.
1. Install via the shadcn CLI
npx shadcn@latest add http://localhost/r/sheet.json2. Use it
import { Sheet, SheetHeader, SheetTitle, SheetDescription,
SheetBody, SheetFooter, SheetClose, SheetTrigger } from "@/components/ui/sheet"
import { Button } from "@/components/ui/button"
<SheetTrigger sheetFor="nav">
<Button variant="outline">Open menu</Button>
</SheetTrigger>
<Sheet id="nav" side="left">
<SheetHeader>
<SheetTitle>Navigation</SheetTitle>
<SheetDescription>Jump to a section.</SheetDescription>
</SheetHeader>
<SheetBody>…links…</SheetBody>
<SheetFooter>
<SheetClose><Button variant="outline">Close</Button></SheetClose>
</SheetFooter>
</Sheet>Or copy the source manually
/** @jsxImportSource hono/jsx */
import type { Child, PropsWithChildren } from "hono/jsx"
import { cn, type ClassValue } from "@/registry/lib/cn"
// Sheet — shadcn-htmx, htmx v4 + Tailwind v4.
//
// An edge-anchored slide-in drawer (left / right / top / bottom) that reuses
// the native HTML <dialog> element + .showModal(). It is the SAME machinery as
// registry/ui/dialog.tsx — we just pin the box to a viewport edge and let it
// fill that edge instead of centring it.
//
// Why native <dialog> + showModal():
// - Focus trap, ESC-to-close, focus restoration, the inert ::backdrop and
// aria-modal="true" all come from the platform — no JS focus management.
// See repos/mdn/files/en-us/web/api/htmldialogelement/showmodal/index.md
// and repos/mdn/files/en-us/web/api/htmldialogelement/index.md.
// - The trigger/close wiring is shared with Dialog: site.js listens for
// [data-dialog-trigger]/[data-dialog-target] (→ .showModal()) and
// [data-dialog-close] (→ .requestClose()/.close()). We add NOTHING new to
// site.js — a Sheet is a <dialog> with data-slot="sheet".
//
// Light dismiss (click the backdrop / dim area to close) is the native
// `closedby="any"` attribute from the HTML Living Standard, NOT a JS hack:
// - "any" — ESC, light dismiss (backdrop click), and code
// - "closerequest" — ESC + code only (the showModal() default)
// - "none" — code only
// See repos/mdn/files/en-us/web/api/htmldialogelement/closedby/index.md and
// repos/mdn/files/en-us/web/html/reference/elements/dialog/index.md (the
// `closedby` attribute). When closedby="any", site.js leaves backdrop handling
// to the browser; otherwise we fall back to the data-close-on-backdrop hook.
//
// Anatomy mirrors shadcn's React Sheet
// (repos/shadcn-ui/apps/v4/registry/new-york-v4/ui/sheet.tsx):
// Sheet / SheetTrigger / SheetClose / SheetHeader / SheetTitle /
// SheetDescription / SheetBody / SheetFooter. shadcn portals an overlay +
// content; we don't need a portal — <dialog> already lives in the top layer.
//
// <SheetTrigger sheetFor="nav">
// <Button variant="outline">Open menu</Button>
// </SheetTrigger>
//
// <Sheet id="nav" side="left">
// <SheetHeader>
// <SheetTitle>Navigation</SheetTitle>
// <SheetDescription>Jump to a section.</SheetDescription>
// </SheetHeader>
// <SheetBody>…links…</SheetBody>
// <SheetFooter>
// <SheetClose><Button variant="outline">Close</Button></SheetClose>
// </SheetFooter>
// </Sheet>
type Side = "top" | "right" | "bottom" | "left"
// Base: a flex column pinned to a viewport edge. The native <dialog> sits in
// the top layer; `hidden open:flex` keeps it out of layout until opened.
// outline-none because focus management is the browser's job (showModal()).
const sheetBase =
"fixed z-50 m-0 flex flex-col gap-4 bg-background p-6 text-foreground shadow-lg outline-none " +
// The slide-in keyframes + reduced-motion guard live in input.css, keyed off
// data-slot="sheet" + data-side. They animate transform from off-screen.
"hidden open:flex " +
// ::backdrop dim — same token palette as Dialog (registry/ui/dialog.tsx).
"backdrop:bg-black/60 backdrop:backdrop-blur-sm"
// Per-edge anchoring + sizing. Side drawers fill the cross-axis (h-full /
// w-full) and cap their main-axis size; top/bottom sheets size to content.
//
// The cross-axis inset is reset to `auto` (left-auto / right-auto / top-auto /
// bottom-auto) because a modal <dialog> inherits the UA rule `inset: 0`. Left
// unchecked, a right-anchored sheet would get BOTH left:0 AND right:0 — over-
// constrained, the browser keeps left:0 and the box hugs the WRONG edge, so it
// covers the dim area and a backdrop click lands on the dialog (no light
// dismiss). Pinning only the anchored edge (and clearing the opposite one)
// keeps the box on its edge and leaves the backdrop clickable.
const sideMap: Record<Side, string> = {
right: "inset-y-0 right-0 left-auto h-full w-3/4 max-w-sm border-l",
left: "inset-y-0 left-0 right-auto h-full w-3/4 max-w-sm border-r",
top: "inset-x-0 top-0 bottom-auto w-full border-b",
bottom: "inset-x-0 bottom-0 top-auto w-full border-t",
}
export function sheetClasses(opts?: { side?: Side; class?: ClassValue }): string {
return cn(sheetBase, sideMap[opts?.side ?? "right"], opts?.class)
}
type SheetProps = PropsWithChildren<{
id: string
// Which viewport edge the sheet slides in from.
side?: Side
// Pre-open on initial render (for htmx swaps that return an already-open
// sheet; site.js promotes <dialog open> to .showModal()).
open?: boolean
// Native `closedby` (HTML Living Standard). Defaults to "any" so a click on
// the dim backdrop dismisses the sheet — the expected drawer behaviour.
// See repos/mdn/.../api/htmldialogelement/closedby/index.md.
closedby?: "any" | "closerequest" | "none"
// Render the X close button in the top-right corner (default true).
showCloseButton?: boolean
class?: ClassValue
ariaLabelledby?: string
ariaDescribedby?: string
}>
export function Sheet(props: SheetProps) {
const {
id,
children,
side = "right",
open,
closedby = "any",
showCloseButton = true,
class: className,
ariaLabelledby,
ariaDescribedby,
} = props
return (
<dialog
id={id}
open={open}
class={sheetClasses({ side, class: className })}
data-slot="sheet"
data-side={side}
// closedby="any" → browser handles ESC + light dismiss natively, so we
// do NOT emit data-close-on-backdrop (site.js skips backdrop handling
// when closedby="any"). For "closerequest"/"none" we add the JS hook so
// a backdrop click still closes when the consumer opted into it.
closedby={closedby}
{...(closedby !== "any" ? { "data-close-on-backdrop": "true" } : {})}
// <dialog> has the implicit role="dialog"; showModal() adds
// aria-modal="true". A labelled/described sheet announces correctly.
aria-labelledby={ariaLabelledby ?? `${id}-title`}
aria-describedby={ariaDescribedby ?? `${id}-description`}
>
{children}
{showCloseButton && (
<button
type="button"
data-dialog-close="true"
aria-label="Close"
class="absolute top-4 right-4 inline-flex size-7 cursor-pointer items-center justify-center rounded-md text-muted-foreground opacity-70 transition-opacity hover:bg-accent hover:text-foreground hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none"
>
<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"
class="size-4"
aria-hidden="true"
>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
</button>
)}
</dialog>
)
}
export function SheetHeader(props: PropsWithChildren<{ class?: ClassValue }>) {
return (
<div
data-slot="sheet-header"
class={cn("flex flex-col gap-1.5 text-left", props.class)}
>
{props.children}
</div>
)
}
export function SheetTitle(
props: PropsWithChildren<{ id?: string; class?: ClassValue }>,
) {
return (
<h2
id={props.id}
data-slot="sheet-title"
class={cn("text-lg leading-none font-semibold", props.class)}
>
{props.children}
</h2>
)
}
export function SheetDescription(
props: PropsWithChildren<{ id?: string; class?: ClassValue }>,
) {
return (
<p
id={props.id}
data-slot="sheet-description"
class={cn("text-sm text-muted-foreground", props.class)}
>
{props.children}
</p>
)
}
// Body — the scrollable middle region. flex-1 + overflow-y-auto so a long body
// (a nav list, a form) scrolls inside the drawer while header/footer stay put.
export function SheetBody(props: PropsWithChildren<{ class?: ClassValue }>) {
return (
<div
data-slot="sheet-body"
class={cn("flex-1 overflow-y-auto text-sm text-foreground", props.class)}
>
{props.children}
</div>
)
}
// Footer — pinned to the bottom of the drawer (mt-auto) with stacked actions.
export function SheetFooter(props: PropsWithChildren<{ class?: ClassValue }>) {
return (
<div
data-slot="sheet-footer"
class={cn("mt-auto flex flex-col gap-2", props.class)}
>
{props.children}
</div>
)
}
// Close — wraps any single child (a Button works) and attaches
// data-dialog-close so site.js calls .requestClose()/.close() on the nearest
// <dialog>. Shares Dialog's close handler — no new site.js.
export function SheetClose(props: PropsWithChildren<{}>) {
return (
<span data-dialog-close="true" class="contents">
{props.children}
</span>
)
}
// Trigger — clicks open the sheet whose id matches sheetFor. Shares Dialog's
// site.js handler (data-dialog-trigger / data-dialog-target → .showModal()).
type SheetTriggerProps = PropsWithChildren<{
sheetFor: string
class?: ClassValue
// "wrapper" (default — wraps the child so the parent can pass a styled
// Button) or "button" (render a native <button> with the provided class).
render?: "wrapper" | "button"
type?: "button" | "submit"
id?: string
}>
export function SheetTrigger(props: SheetTriggerProps) {
const {
sheetFor,
render = "wrapper",
children,
class: className,
id,
type = "button",
} = props
if (render === "button") {
return (
<button
id={id}
type={type}
class={cn(className)}
data-dialog-trigger="true"
data-dialog-target={sheetFor}
aria-haspopup="dialog"
>
{children}
</button>
)
}
return (
<span
data-dialog-trigger="true"
data-dialog-target={sheetFor}
class="contents"
>
{children}
</span>
)
}
1. Save the file
Copy sheet.html into templates/components/.
2. Use it
{% from "components/sheet.html" import sheet, sheet_trigger %}
{{ sheet_trigger("Open menu", sheet_for="nav",
class_="…outline button classes…") }}
{% call sheet(id="nav", side="left", title="Navigation",
description="Jump to a section.") %}
<div data-slot="sheet-body" class="flex-1 overflow-y-auto text-sm text-foreground">
…links…
</div>
<div data-slot="sheet-footer" class="mt-auto flex flex-col gap-2">
<button type="button" data-dialog-close="true">Close</button>
</div>
{% endcall %}View source
{# Sheet macros — shadcn-htmx, htmx v4 + Tailwind v4.
Mirrors registry/ui/sheet.tsx. An edge-anchored slide-in drawer built on the
native <dialog> element + .showModal(); reuses the trigger/close wiring in
public/site.js (data-dialog-trigger / data-dialog-close). Light dismiss is
the native closedby="any" attribute (HTML Living Standard) — no JS.
See repos/mdn/files/en-us/web/api/htmldialogelement/closedby/index.md.
Usage:
{% from "components/sheet.html" import sheet, sheet_trigger %}
{{ sheet_trigger("Open menu", sheet_for="nav", class_="…btn classes") }}
{% call sheet(id="nav", side="left", title="Navigation",
description="Jump to a section.") %}
<div data-slot="sheet-body" class="flex-1 overflow-y-auto text-sm text-foreground">
<!-- body content -->
</div>
<div data-slot="sheet-footer" class="mt-auto flex flex-col gap-2">
<button type="button" data-dialog-close="true" class="…">Close</button>
</div>
{% endcall %} #}
{% macro sheet(
id,
side="right",
title=none,
description=none,
show_close_button=true,
open=false,
closedby="any",
extra_class=""
) %}
{# Cross-axis inset reset to `auto` so the UA modal rule `inset: 0` doesn't
over-constrain a side-anchored <dialog> (both left:0 and right:0 → box hugs
the wrong edge and covers the backdrop, breaking closedby="any" light
dismiss). Pin only the anchored edge. #}
{%- set sides = {
"right": "inset-y-0 right-0 left-auto h-full w-3/4 max-w-sm border-l",
"left": "inset-y-0 left-0 right-auto h-full w-3/4 max-w-sm border-r",
"top": "inset-x-0 top-0 bottom-auto w-full border-b",
"bottom": "inset-x-0 bottom-0 top-auto w-full border-t"
} -%}
{%- set base -%}
fixed z-50 m-0 flex flex-col gap-4 bg-background p-6 text-foreground shadow-lg outline-none hidden open:flex backdrop:bg-black/60 backdrop:backdrop-blur-sm
{%- endset -%}
<dialog id="{{ id }}"
{%- if open %} open{% endif %}
closedby="{{ closedby }}"
class="{{ base }} {{ sides[side] }} {{ extra_class }}"
data-slot="sheet"
data-side="{{ side }}"
{%- if closedby != "any" %} data-close-on-backdrop="true"{% endif %}
aria-labelledby="{{ id }}-title"
aria-describedby="{{ id }}-description">
{%- if title or description %}
<div data-slot="sheet-header" class="flex flex-col gap-1.5 text-left">
{% if title %}<h2 id="{{ id }}-title" data-slot="sheet-title" class="text-lg leading-none font-semibold">{{ title }}</h2>{% endif %}
{% if description %}<p id="{{ id }}-description" data-slot="sheet-description" class="text-sm text-muted-foreground">{{ description }}</p>{% endif %}
</div>
{%- endif %}
{{ caller() }}
{%- if show_close_button %}
<button type="button" data-dialog-close="true" aria-label="Close"
class="absolute top-4 right-4 inline-flex size-7 cursor-pointer items-center justify-center rounded-md text-muted-foreground opacity-70 transition-opacity hover:bg-accent hover:text-foreground hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none">
<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" class="size-4" aria-hidden="true">
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
</svg>
</button>
{%- endif %}
</dialog>
{% endmacro %}
{% macro sheet_trigger(label, sheet_for, type="button", id=none, class_="") %}
<button {% if id %} id="{{ id }}"{% endif %}
type="{{ type }}"
class="{{ class_ }}"
data-dialog-trigger="true"
data-dialog-target="{{ sheet_for }}"
aria-haspopup="dialog">{{ label }}</button>
{% endmacro %}
1. Save the file
Add sheet.tmpl alongside your templates.
2. Use it
{{template "sheet_trigger" (dict
"Label" "Open menu" "SheetFor" "nav" "Class" "…button classes…")}}
{{template "sheet" (dict
"ID" "nav" "Side" "left" "Title" "Navigation"
"Description" "Jump to a section."
"Body" (htmlSafe `<div data-slot="sheet-body" class="flex-1 overflow-y-auto text-sm text-foreground">…</div>
<div data-slot="sheet-footer" class="mt-auto flex flex-col gap-2">
<button type="button" data-dialog-close="true">Close</button>
</div>`)
)}}View source
{{/*
Sheet template — shadcn-htmx, htmx v4 + Tailwind v4.
Mirrors registry/ui/sheet.tsx.
An edge-anchored slide-in drawer (left / right / top / bottom) built on the
native <dialog> element + .showModal(). Reuses the trigger/close wiring in
public/site.js (data-dialog-trigger / data-dialog-close). Light dismiss is
the native closedby="any" attribute (HTML Living Standard) — no JS.
See repos/mdn/files/en-us/web/api/htmldialogelement/closedby/index.md.
Usage:
type SheetArgs struct {
ID, Side, Title, Description string // Side: left|right|top|bottom
Body template.HTML // already-rendered HTML
ShowCloseButton bool // default true
Open bool
ClosedBy string // default "any"
}
tpl.ExecuteTemplate(w, "sheet", SheetArgs{
ID: "nav", Side: "left", Title: "Navigation",
Description: "Jump to a section.",
Body: template.HTML(`
<div data-slot="sheet-body" class="flex-1 overflow-y-auto text-sm text-foreground">…</div>
<div data-slot="sheet-footer" class="mt-auto flex flex-col gap-2">
<button type="button" data-dialog-close="true">Close</button>
</div>`),
})
Companion: "sheet_trigger" template (below) renders the open button.
*/}}
{{define "sheet"}}
{{- $base := "fixed z-50 m-0 flex flex-col gap-4 bg-background p-6 text-foreground shadow-lg outline-none hidden open:flex backdrop:bg-black/60 backdrop:backdrop-blur-sm" -}}
{{- $side := or .Side "right" -}}
{{/* Cross-axis inset reset to `auto` so the UA modal rule `inset: 0` doesn't over-constrain a side-anchored <dialog> (both left:0 and right:0 → box hugs the wrong edge and covers the backdrop, breaking closedby="any" light dismiss). Pin only the anchored edge. */}}
{{- $sides := dict "right" "inset-y-0 right-0 left-auto h-full w-3/4 max-w-sm border-l" "left" "inset-y-0 left-0 right-auto h-full w-3/4 max-w-sm border-r" "top" "inset-x-0 top-0 bottom-auto w-full border-b" "bottom" "inset-x-0 bottom-0 top-auto w-full border-t" -}}
{{- $closedBy := or .ClosedBy "any" -}}
{{- $showCloseButton := true -}}{{- if .ShowCloseButtonSet}}{{$showCloseButton = .ShowCloseButton}}{{end -}}
<dialog id="{{.ID}}"
{{- if .Open}} open{{end}}
closedby="{{$closedBy}}"
class="{{$base}} {{index $sides $side}}"
data-slot="sheet"
data-side="{{$side}}"
{{- if ne $closedBy "any"}} data-close-on-backdrop="true"{{end}}
aria-labelledby="{{.ID}}-title"
aria-describedby="{{.ID}}-description">
{{- if or .Title .Description}}
<div data-slot="sheet-header" class="flex flex-col gap-1.5 text-left">
{{- if .Title}}
<h2 id="{{.ID}}-title" data-slot="sheet-title" class="text-lg leading-none font-semibold">{{.Title}}</h2>
{{- end}}
{{- if .Description}}
<p id="{{.ID}}-description" data-slot="sheet-description" class="text-sm text-muted-foreground">{{.Description}}</p>
{{- end}}
</div>
{{- end}}
{{.Body}}
{{- if $showCloseButton}}
<button type="button" data-dialog-close="true" aria-label="Close"
class="absolute top-4 right-4 inline-flex size-7 cursor-pointer items-center justify-center rounded-md text-muted-foreground opacity-70 transition-opacity hover:bg-accent hover:text-foreground hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none">
<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" class="size-4" aria-hidden="true">
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
</svg>
</button>
{{- end}}
</dialog>
{{end}}
{{define "sheet_trigger"}}
<button {{if .ID}}id="{{.ID}}"{{end}}
type="{{or .Type "button"}}"
class="{{.Class}}"
data-dialog-trigger="true"
data-dialog-target="{{.SheetFor}}"
aria-haspopup="dialog">{{.Label}}</button>
{{end}}
1. Save the file
Drop sheet.ex into lib/my_app_web/components/.
2. Use it
<.sheet_trigger sheet_for="nav" class="…outline-button…">
Open menu
</.sheet_trigger>
<.sheet id="nav" side="left" title="Navigation"
description="Jump to a section.">
<div data-slot="sheet-body" class="flex-1 overflow-y-auto text-sm text-foreground">
…links…
</div>
<div data-slot="sheet-footer" class="mt-auto flex flex-col gap-2">
<button type="button" data-dialog-close="true">Close</button>
</div>
</.sheet>View source
defmodule ShadcnHtmx.Components.Sheet do
@moduledoc """
Sheet — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.
Mirrors registry/ui/sheet.tsx. An edge-anchored slide-in drawer (left /
right / top / bottom) built on the native <dialog> element + .showModal().
Reuses the trigger/close wiring in public/site.js (data-dialog-trigger /
data-dialog-close). Light dismiss is the native `closedby="any"` attribute
(HTML Living Standard) — no JS.
See repos/mdn/files/en-us/web/api/htmldialogelement/closedby/index.md.
## Examples
<.sheet_trigger sheet_for="nav" class="…btn-classes…">
Open menu
</.sheet_trigger>
<.sheet id="nav" side="left" title="Navigation"
description="Jump to a section.">
<div data-slot="sheet-body" class="flex-1 overflow-y-auto text-sm text-foreground">
<!-- body content -->
</div>
<div data-slot="sheet-footer" class="mt-auto flex flex-col gap-2">
<button type="button" data-dialog-close="true">Close</button>
</div>
</.sheet>
"""
use Phoenix.Component
@sheet_base "fixed z-50 m-0 flex flex-col gap-4 bg-background p-6 text-foreground shadow-lg outline-none " <>
"hidden open:flex " <>
"backdrop:bg-black/60 backdrop:backdrop-blur-sm"
# Cross-axis inset reset to `auto` so the UA modal rule `inset: 0` doesn't
# over-constrain a side-anchored <dialog> (both left:0 and right:0 → box hugs
# the wrong edge and covers the backdrop, breaking closedby="any" light
# dismiss). Pin only the anchored edge.
@sides %{
"right" => "inset-y-0 right-0 left-auto h-full w-3/4 max-w-sm border-l",
"left" => "inset-y-0 left-0 right-auto h-full w-3/4 max-w-sm border-r",
"top" => "inset-x-0 top-0 bottom-auto w-full border-b",
"bottom" => "inset-x-0 bottom-0 top-auto w-full border-t"
}
attr :id, :string, required: true
attr :side, :string, default: "right", values: ~w(top right bottom left)
attr :title, :string, default: nil
attr :description, :string, default: nil
attr :show_close_button, :boolean, default: true
attr :open, :boolean, default: false
# Native HTML `closedby` attribute (HTML Living Standard). Defaults to "any"
# so a backdrop click light-dismisses the drawer.
# See repos/mdn/files/en-us/web/api/htmldialogelement/closedby/index.md.
attr :closedby, :string, default: "any", values: ~w(any closerequest none)
attr :class, :string, default: nil
slot :inner_block, required: true
def sheet(assigns) do
assigns =
assigns
|> assign(:base, @sheet_base)
|> assign(:side_class, @sides[assigns.side])
~H"""
<dialog
id={@id}
open={@open}
closedby={@closedby}
class={[@base, @side_class, @class]}
data-slot="sheet"
data-side={@side}
data-close-on-backdrop={@closedby != "any" && "true"}
aria-labelledby={"#{@id}-title"}
aria-describedby={"#{@id}-description"}
>
<div :if={@title || @description} data-slot="sheet-header" class="flex flex-col gap-1.5 text-left">
<h2 :if={@title} id={"#{@id}-title"} data-slot="sheet-title" class="text-lg leading-none font-semibold">{@title}</h2>
<p :if={@description} id={"#{@id}-description"} data-slot="sheet-description" class="text-sm text-muted-foreground">{@description}</p>
</div>
{render_slot(@inner_block)}
<button
:if={@show_close_button}
type="button"
data-dialog-close="true"
aria-label="Close"
class="absolute top-4 right-4 inline-flex size-7 cursor-pointer items-center justify-center rounded-md text-muted-foreground opacity-70 transition-opacity hover:bg-accent hover:text-foreground hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none"
>
<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" class="size-4" aria-hidden="true">
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
</svg>
</button>
</dialog>
"""
end
attr :sheet_for, :string, required: true
attr :type, :string, default: "button"
attr :class, :string, default: nil
attr :rest, :global
slot :inner_block, required: true
def sheet_trigger(assigns) do
~H"""
<button
type={@type}
class={@class}
data-dialog-trigger="true"
data-dialog-target={@sheet_for}
aria-haspopup="dialog"
{@rest}
>
{render_slot(@inner_block)}
</button>
"""
end
end
1. Save the file
Paste the markup; relies only on theme tokens.
2. Use it
<button data-dialog-trigger="true" data-dialog-target="nav">Open menu</button>
<dialog id="nav" data-slot="sheet" data-side="left" closedby="any"
class="fixed z-50 m-0 flex flex-col gap-4 … inset-y-0 left-0 right-auto h-full w-3/4 max-w-sm border-r">
<h2>Navigation</h2>
<p>Jump to a section.</p>
<button data-dialog-close="true">Close</button>
</dialog>
<script>/* see snippets/sheet.html for the open/close wiring */</script>View source
<!--
shadcn-htmx — raw HTML sheet snippet.
An edge-anchored slide-in drawer (left / right / top / bottom) built on the
native <dialog> element + .showModal(). Open/close is JS-driven because
<dialog> has no built-in trigger attribute; we lean on the same tiny script
that drives Dialog (see public/site.js), which listens for
data-dialog-trigger / data-dialog-close clicks.
Light dismiss (click the dim backdrop to close) is the NATIVE closedby="any"
attribute from the HTML Living Standard — no JS for that part. The browser
also handles ESC, focus trap, focus restoration and aria-modal.
See https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/closedBy
Minimal inline JS to copy alongside this snippet (open + close only; the
backdrop click is handled natively by closedby="any"):
<script>
document.addEventListener('click', (e) => {
const t = e.target.closest('[data-dialog-trigger]')
if (t) document.getElementById(t.dataset.dialogTarget)?.showModal()
const c = e.target.closest('[data-dialog-close]')
if (c) (c.closest('dialog')?.requestClose?.() ?? c.closest('dialog')?.close())
})
</script>
-->
<!-- Trigger button -->
<button type="button"
data-dialog-trigger="true"
data-dialog-target="nav-sheet"
aria-haspopup="dialog"
class="inline-flex h-9 items-center justify-center rounded-md border bg-background px-4 text-sm font-medium shadow-xs hover:bg-accent">
Open menu
</button>
<!-- The sheet itself (left-anchored drawer) -->
<dialog id="nav-sheet"
data-slot="sheet"
data-side="left"
closedby="any"
aria-labelledby="nav-sheet-title"
aria-describedby="nav-sheet-description"
class="fixed z-50 m-0 flex flex-col gap-4 bg-background p-6 text-foreground shadow-lg outline-none hidden open:flex backdrop:bg-black/60 backdrop:backdrop-blur-sm inset-y-0 left-0 right-auto h-full w-3/4 max-w-sm border-r">
<div data-slot="sheet-header" class="flex flex-col gap-1.5 text-left">
<h2 id="nav-sheet-title" data-slot="sheet-title"
class="text-lg leading-none font-semibold">
Navigation
</h2>
<p id="nav-sheet-description" data-slot="sheet-description"
class="text-sm text-muted-foreground">
Jump to a section. Click outside or press Esc to close.
</p>
</div>
<div data-slot="sheet-body" class="flex-1 overflow-y-auto text-sm text-foreground">
<nav class="grid gap-1">
<a href="#overview" class="rounded-md px-2 py-1.5 hover:bg-accent">Overview</a>
<a href="#install" class="rounded-md px-2 py-1.5 hover:bg-accent">Installation</a>
<a href="#usage" class="rounded-md px-2 py-1.5 hover:bg-accent">Usage</a>
</nav>
</div>
<div data-slot="sheet-footer" class="mt-auto flex flex-col gap-2">
<button type="button" data-dialog-close="true"
class="inline-flex h-9 items-center justify-center rounded-md border bg-background px-4 text-sm font-medium shadow-xs hover:bg-accent">
Close
</button>
</div>
<button type="button" data-dialog-close="true" aria-label="Close"
class="absolute top-4 right-4 inline-flex size-7 cursor-pointer items-center justify-center rounded-md text-muted-foreground opacity-70 transition-opacity hover:bg-accent hover:text-foreground hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none">
<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" class="size-4" aria-hidden="true">
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
</svg>
</button>
</dialog>
Examples
Side drawer — slides in from the right
Click the trigger. The drawer slides in from the edge, traps focus and dims the page. ESC, the backdrop, the X, or Close all dismiss it.
A Sheet is the same native modal as Dialog — showModal() gives us the focus trap, ESC handling, focus restoration and a real ::backdrop — but anchored to a viewport edge instead of centred. Clicking the dim area closes it through the native closedby="any" attribute, so we don't ship any backdrop-click JS for it.
Try ESC, the X, the dim backdrop, or Close — all dismiss it.
<SheetTrigger sheetFor="settings">
<Button variant="outline">Open settings…</Button>
</SheetTrigger>
<Sheet id="settings" side="right">
<SheetHeader>
<SheetTitle>Settings</SheetTitle>
<SheetDescription>Manage your preferences.</SheetDescription>
</SheetHeader>
<SheetBody class="grid gap-3 py-4">
<Label htmlFor="name">Display name</Label>
<Input id="name" name="name" value="Mehmet" />
</SheetBody>
<SheetFooter>
<SheetClose><Button variant="outline">Close</Button></SheetClose>
<Button>Save</Button>
</SheetFooter>
</Sheet>{{ sheet_trigger("Open settings…", sheet_for="settings",
class_="…outline button classes…") }}
{% call sheet(id="settings", side="right", title="Settings",
description="Manage your preferences.") %}
<div data-slot="sheet-body" class="flex-1 overflow-y-auto py-4 text-sm text-foreground">
{{ label("Display name", for_="name") }}
{{ input(id="name", name="name", value="Mehmet") }}
</div>
<div data-slot="sheet-footer" class="mt-auto flex flex-col gap-2">
<button type="button" data-dialog-close="true">Close</button>
<button type="submit">Save</button>
</div>
{% endcall %}{{template "sheet_trigger" (dict
"Label" "Open settings…" "SheetFor" "settings" "Class" "…btn classes…")}}
{{template "sheet" (dict
"ID" "settings" "Side" "right" "Title" "Settings"
"Description" "Manage your preferences."
"Body" (htmlSafe `<div data-slot="sheet-body" class="flex-1 overflow-y-auto py-4 text-sm text-foreground">…</div>
<div data-slot="sheet-footer" class="mt-auto flex flex-col gap-2">
<button type="button" data-dialog-close="true">Close</button>
<button type="submit">Save</button>
</div>`)
)}}<.sheet_trigger sheet_for="settings" class="…outline-button…">
Open settings…
</.sheet_trigger>
<.sheet id="settings" side="right" title="Settings"
description="Manage your preferences.">
<div data-slot="sheet-body" class="flex-1 overflow-y-auto py-4 text-sm text-foreground">
<.label for="name">Display name</.label>
<.input id="name" name="name" value={@name} />
</div>
<div data-slot="sheet-footer" class="mt-auto flex flex-col gap-2">
<button type="button" data-dialog-close="true">Close</button>
<button type="submit">Save</button>
</div>
</.sheet><div class="flex flex-col items-center gap-3">
<span data-dialog-trigger="true" data-dialog-target="ex-basic-sheet" class="contents">
<button type="button" class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:pointer-events-none [&.htmx-request]:opacity-70 border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50 h-9 px-4 py-2 has-[>svg]:px-3" data-slot="button" data-variant="outline" data-size="default">Open settings…</button>
</span>
<p class="text-xs text-muted-foreground">Try ESC, the X, the dim backdrop, or Close — all dismiss it.</p>
</div>Four edges — left, right, top, bottom
The same component slides in from any edge. side picks the anchor; side drawers fill the cross-axis, top/bottom sheets size to content.
Only the anchoring classes change between sides — the side prop swaps inset-y-0 right-0 for inset-x-0 bottom-0 and so on. The slide direction is driven by data-side in CSS, so a left sheet enters from the left and a bottom sheet rises from below.
<SheetTrigger sheetFor="left"><Button>Left</Button></SheetTrigger>
<Sheet id="left" side="left">…</Sheet>
<SheetTrigger sheetFor="top"><Button>Top</Button></SheetTrigger>
<Sheet id="top" side="top">…</Sheet>
<SheetTrigger sheetFor="bottom"><Button>Bottom</Button></SheetTrigger>
<Sheet id="bottom" side="bottom">…</Sheet>{{ sheet_trigger("Left", sheet_for="left") }}
{% call sheet(id="left", side="left", title="Left") %}…{% endcall %}
{{ sheet_trigger("Bottom", sheet_for="bottom") }}
{% call sheet(id="bottom", side="bottom", title="Bottom") %}…{% endcall %}{{template "sheet_trigger" (dict "Label" "Left" "SheetFor" "left")}}
{{template "sheet" (dict "ID" "left" "Side" "left" "Title" "Left" "Body" …)}}
{{template "sheet_trigger" (dict "Label" "Bottom" "SheetFor" "bottom")}}
{{template "sheet" (dict "ID" "bottom" "Side" "bottom" "Title" "Bottom" "Body" …)}}<.sheet_trigger sheet_for="left">Left</.sheet_trigger>
<.sheet id="left" side="left" title="Left">…</.sheet>
<.sheet_trigger sheet_for="bottom">Bottom</.sheet_trigger>
<.sheet id="bottom" side="bottom" title="Bottom">…</.sheet><div class="flex flex-wrap items-center justify-center gap-2">
<span data-dialog-trigger="true" data-dialog-target="ex-side-left" class="contents">
<button type="button" class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:pointer-events-none [&.htmx-request]:opacity-70 border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50 h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5" data-slot="button" data-variant="outline" data-size="sm">Left</button>
</span>
<span data-dialog-trigger="true" data-dialog-target="ex-side-top" class="contents">
<button type="button" class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:pointer-events-none [&.htmx-request]:opacity-70 border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50 h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5" data-slot="button" data-variant="outline" data-size="sm">Top</button>
</span>
<span data-dialog-trigger="true" data-dialog-target="ex-side-bottom" class="contents">
<button type="button" class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:pointer-events-none [&.htmx-request]:opacity-70 border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50 h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5" data-slot="button" data-variant="outline" data-size="sm">Bottom</button>
</span>
</div>Further reading
htmx — stream the sheet body from the server
The trigger doesn't pre-render the drawer. It GETs HTML, htmx swaps it into a host slot, then site.js promotes the inserted <dialog open> to a modal.
Useful when the drawer needs server data (a cart, a filter panel, an editable record). hx-get fetches the markup, hx-target="#sheet-host" drops it into a slot, and the htmx:after:swap listener in site.js strips the open attribute and calls .showModal() so the focus trap and backdrop kick in — identical to Dialog.
<Button hx-get="/api/cart" hx-target="#sheet-host" hx-swap="innerHTML">
Open cart
</Button>
<div id="sheet-host" />
{/* The server returns <dialog data-slot="sheet" side="right" open>…</dialog>.
site.js's htmx:after:swap listener promotes it to .showModal(). */}<button hx-get="/api/cart" hx-target="#sheet-host" hx-swap="innerHTML">
Open cart
</button>
<div id="sheet-host"></div><button hx-get="/api/cart" hx-target="#sheet-host" hx-swap="innerHTML">
Open cart
</button>
<div id="sheet-host"></div><button hx-get={~p"/api/cart"} hx-target="#sheet-host" hx-swap="innerHTML">
Open cart
</button>
<div id="sheet-host"></div><div class="flex flex-col items-center gap-3">
<button type="button" class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:pointer-events-none [&.htmx-request]:opacity-70 border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50 h-9 px-4 py-2 has-[>svg]:px-3" data-slot="button" data-variant="outline" data-size="default" hx-get="/sheet/server-rendered" hx-target="#sheet-host" hx-swap="innerHTML">Fetch & open drawer</button>
<div id="sheet-host">
</div>
</div>Further reading
API Reference
<Sheet>
| Prop | Type | Default | Description |
|---|---|---|---|
id* | string | — | Used by SheetTrigger's sheetFor prop to open this sheet. Also seeds the title/description ids (`{id}-title`, `{id}-description`). |
side | "top"|"right"|"bottom"|"left" | "right" | Viewport edge the drawer slides in from. Side drawers fill the cross-axis; top/bottom sheets size to content.MDNinset / inset-x / inset-y |
open | boolean | false | Pre-open at initial render (for htmx-fetched sheets; site.js promotes <dialog open> to .showModal()). |
closedby | "any"|"closerequest"|"none" | "any" | Native HTML attribute. "any" = ESC + backdrop light dismiss, "closerequest" = ESC + code only, "none" = code only. When not "any", a data-close-on-backdrop hook keeps backdrop-click dismissal.MDN<dialog closedby> |
showCloseButton | boolean | true | Render the X button in the top-right corner. |
ariaLabel | string | — | Accessible name when no visible <label>.MDNaria-label |
ariaLabelledby | string | — | Id of a visible element providing the accessible name.MDNaria-labelledby |
class | string | — | Extra Tailwind classes appended to the root element. |
* required