shshadcn-htmx

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.json

2. Use it

components/ui/sheet.tsx
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
components/ui/sheet.tsx
/** @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

templates/components/sheet.html
{% 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
templates/components/sheet.html
{# 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

components/sheet.tmpl
{{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
components/sheet.tmpl
{{/*
  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

lib/my_app_web/components/sheet.ex
<.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
lib/my_app_web/components/sheet.ex
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

snippets/sheet.html
<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
snippets/sheet.html
<!--
  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 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.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-[&gt;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 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.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-[&gt;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 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.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-[&gt;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 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.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-[&gt;svg]:px-2.5" data-slot="button" data-variant="outline" data-size="sm">Bottom</button>
  </span>
</div>

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 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.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-[&gt;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 &amp; open drawer</button>
  <div id="sheet-host">
  </div>
</div>

API Reference

<Sheet>

PropTypeDefaultDescription
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
openbooleanfalse
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>
showCloseButtonbooleantrue
Render the X button in the top-right corner.
ariaLabelstring
Accessible name when no visible <label>.MDNaria-label
ariaLabelledbystring
Id of a visible element providing the accessible name.MDNaria-labelledby
classstring
Extra Tailwind classes appended to the root element.

* required