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