Docs Platform

Sheet

A modal panel that slides in from an edge rather than appearing centred like Dialog. Sheet and Drawer are two names for one machine, @grassroot/ui-machines/sheet, with a different styled recipe and set of sides on each. This page is Sheet's walkthrough; Drawer's page covers the same contract from the other side.

Explore the layers

Machine (@grassroot/ui-machines/sheet) is pure state: no DOM, no framework, no styling. It is the same machine Drawer uses. Headless (@grassroot/ui-headless-<framework>/sheet) adds Dialog-shaped accessible DOM/ARIA on top of it, per framework. Styled (@grassroot/ui-<framework>) is Grassroot's edge-panel visual recipe built on the headless layer. Vanilla (mountSheet, from @grassroot/ui-headless-core/sheet) is the framework-free mount helper.

Layer
Package
Ships behaviour
Machine
@grassroot/ui-machines/sheet
Open binding, dismissable guard, no DOM. Shared verbatim with Drawer.
Headless
@grassroot/ui-headless-{react,vue,solid,svelte,angular}/sheet
Dialog-shaped modal DOM/ARIA, data-side, no visual recipe.
Vanilla
@grassroot/ui-headless-core/sheet (mountSheet)
The same headless contract with no framework runtime at all.
Styled
@grassroot/ui-{react,vue,solid,svelte,angular}
Grassroot's edge-panel visual recipe, tokens and classes only.
Layer
Machine
Package
@grassroot/ui-machines/sheet
Ships behaviour
Open binding, dismissable guard, no DOM. Shared verbatim with Drawer.
Layer
Headless
Package
@grassroot/ui-headless-{react,vue,solid,svelte,angular}/sheet
Ships behaviour
Dialog-shaped modal DOM/ARIA, data-side, no visual recipe.
Layer
Vanilla
Package
@grassroot/ui-headless-core/sheet (mountSheet)
Ships behaviour
The same headless contract with no framework runtime at all.
Layer
Styled
Package
@grassroot/ui-{react,vue,solid,svelte,angular}
Ships behaviour
Grassroot's edge-panel visual recipe, tokens and classes only.

Behaviour contract

This is the one thing every layer agrees on. The machine decides it, the headless layer exposes it as DOM/ARIA, and the styled layer inherits it unchanged.

SheetInput:  open? / defaultOpen?      (ONE state-backed open binding)
             dismissable?: boolean    (default true)
             side?: "left" | "right" (connector-only; the machine never reads it)
StateValue:  "open" | "closed"
Context:     { mode: "controlled" | "uncontrolled" }
Events:      OPEN  { reason: "trigger" | "programmatic" }
             CLOSE { reason: "escape" | "outside-click" | "close-trigger" | "programmatic" }
Command:     OPEN_CHANGE_REQUEST { open, reason }
  • Open binding: the same open/defaultOpen shape Dialog uses, fixed to controlled or uncontrolled mode at first start. A post-start mode flip produces the standard binding-mode-change diagnostic and leaves state unchanged.
  • Dismissable defaults to true, unlike AlertDialog, where it defaults to false. When it's set to false, Escape and outside-click become true no-ops; the close trigger and a programmatic close remain allowed either way.
  • side is connector-only styling metadata. Sheet accepts "left" | "right" and defaults to "right". It never enters machine state and never branches a transition, and the machine ignores it entirely. The connector emits it as data-side.
  • No-ops: OPEN while already open and CLOSE while already closed are true no-ops: no command, no version bump.
  • Forms: none.

Managers & dismissal

Sheet follows Dialog's proven manager contract exactly, so nothing about focus, scroll, or dismissal is reimplemented for Sheet specifically.

  • One activation call per open-commit. The connector/adapter calls activateSheetManagers (Drawer calls the sibling activateDrawerManagers), which pushes a modal layer (pushLayer({ modal: true })), traps focus inside Content, and (through the layer manager) acquires the reference-counted scroll lock. All three release together on close-commit or unmount.
  • The layer owns Escape and outside pointerdown. Its onDismiss sends the machine's own CLOSE event with the matching reason; it never mutates or bypasses machine state directly. Content and Trigger are both registered as layer containment elements, so clicking either one never counts as an outside click.
  • No styled wrapper owns a scrim click, an Escape handler, a focus-trap call, a scroll-lock call, or a manual portal. Backdrop itself has no dismissal handler of its own. Dismissal is entirely the layer's job.

Anatomy

Sheet exposes the same Dialog-shaped parts Drawer exposes, under data-scope="sheet":

Part
data-part
Render rule
Root
–
context/service owner; renders no element
Trigger
trigger
always mounted
Backdrop
backdrop
mounted while open
Positioner
positioner
mounted while open, portalled
Content
content
mounted while open; role="dialog" aria-modal="true"
Title
title
mounted while open; labels Content when present
Description
description
mounted while open; describes Content when present
CloseTrigger
close-trigger
mounted while open
Part
Root
data-part
–
Render rule
context/service owner; renders no element
Part
Trigger
data-part
trigger
Render rule
always mounted
Part
Backdrop
data-part
backdrop
Render rule
mounted while open
Part
Positioner
data-part
positioner
Render rule
mounted while open, portalled
Part
Content
data-part
content
Render rule
mounted while open; role="dialog" aria-modal="true"
Part
Title
data-part
title
Render rule
mounted while open; labels Content when present
Part
Description
data-part
description
Render rule
mounted while open; describes Content when present
Part
CloseTrigger
data-part
close-trigger
Render rule
mounted while open

Every rendered part carries data-scope="sheet", data-part, and data-state="open" | "closed". Content, Title, Description, and CloseTrigger are unmounted while closed, matching the legacy wrappers exactly, the same unmount-on-close model Dialog uses. aria-labelledby/aria-describedby on Content are emitted only when the matching Title/Description part is actually registered, never pointed at an id that doesn't exist. IDs are deterministic per scope seed but their exact format is not itself versioned. data-side mirrors the side input, a pure styling hook that the machine never reads.

Keyboard, pointer, focus

Legacy-compatible throughout, following Dialog's manager contract:

Trigger
When
Effect
Click
Trigger
Sends OPEN { reason: "trigger" }.
Click
CloseTrigger
Sends CLOSE { reason: "close-trigger" }.
Escape / outside pointerdown
open, dismissable
The layer sends the matching close reason.
Tab / Shift+Tab
open
Wraps within Content; focus that escapes programmatically is reclaimed.
Close (any reason)
–
Focus returns to the element that was active when the managers were activated.
Trigger
Click
When
Trigger
Effect
Sends OPEN { reason: "trigger" }.
Trigger
Click
When
CloseTrigger
Effect
Sends CLOSE { reason: "close-trigger" }.
Trigger
Escape / outside pointerdown
When
open, dismissable
Effect
The layer sends the matching close reason.
Trigger
Tab / Shift+Tab
When
open
Effect
Wraps within Content; focus that escapes programmatically is reclaimed.
Trigger
Close (any reason)
When
–
Effect
Focus returns to the element that was active when the managers were activated.

Forms

None.

Accessibility

Content carries role="dialog" and aria-modal="true" whenever it's mounted. aria-labelledby and aria-describedby are conditionally present, only when a Title or Description part actually exists. Everything about focus movement, the Tab-trap, and the ref-counted scroll lock is covered above under "Managers & dismissal", since that is manager-owned DOM behaviour rather than an ARIA attribute. connectSheet and every prop getter are DOM-free and SSR-safe; overlay parts do not render a live portal during SSR, and each adapter uses its existing Dialog portal/hydration idiom. Managers are a mount-time-only concern and are never created during SSR.

Evidence & further reading