Drawer
Primitive

Drawer

Headless modal side-sheet primitives with drawer-specific parts and placement metadata.

Live behavior

Drawer in motion

Interactive
Atom behavior · App-owned appearance

Open the sheet and verify focus returns to the trigger.

waiting for input

When to Use

Use Drawer when a modal task or navigation panel should enter from an edge of the screen. Use Dialog when the panel belongs in the center or has no edge meaning. Use an ordinary inline panel when the page should remain fully usable while it is open. Drawer provides behavior and placement metadata, but your application owns its visual position and motion.

Features

The behavior Atom owns before your product adds appearance.

  • Supports controlled and uncontrolled open state with close reasons.
  • Traps and restores focus, locks scrolling, and contains descendant portals.
  • Supports independent Escape, backdrop, and Overlay dismissal controls.
  • Exposes consumer-provided placement through [data-placement].
  • Supports keep-mounted content for consumer-owned transitions.
  • Provides generated title and description relationships.
  • Remains headless: placement does not apply layout or animation.

Import

tsx
import { Drawer } from "@flowstack-ui/atom";

Anatomy

tsx
<Drawer.Root>
  <Drawer.Trigger />
  <Drawer.Portal>
    <Drawer.Overlay />
    <Drawer.Content>
      <Drawer.Title />
      <Drawer.Description />
      <Drawer.Close />
    </Drawer.Content>
  </Drawer.Portal>
</Drawer.Root>

API Reference

Root

Owns drawer state, dismissal settings, generated IDs, and focus restoration. It renders no wrapper.

PropTypeDefault
openboolean-
defaultOpenbooleanfalse
onOpenChange(open: boolean, reason?: ModalCloseReason) => void-
closeOnEscapebooleantrue
closeOnBackdropClickbooleantrue
disabledbooleanfalse
keepMountedbooleanfalse

Trigger

Opens the drawer. It renders a native button by default and supplies button semantics when composed onto a custom element.

PropTypeDefault
asChildbooleanfalse
renderRenderProp-
ARIA attributeValues
role"button" for custom elements
aria-haspopup"dialog"
aria-expandedCurrent open state
aria-controlsContent ID while open
aria-disabled"true" when Root is disabled
Data attributeValues
[data-slot]"drawer-trigger"
[data-state]"open" | "closed"
[data-disabled]Present when disabled

Portal

Moves Overlay and Content to document.body by default without creating a wrapper.

PropTypeDefault
containerHTMLElement | nulldocument.body
disabledbooleanfalse

The container must be an HTMLElement in the current document. ShadowRoot, DocumentFragment, and cross-document containers are unsupported. Atom keeps the ancestor paths to separate Overlay and Content portals, inline Content, nested same-document containers, and registered branches active while making background subtrees inert. Global Toast/live-region containers are background unless they are within an owned path or registered with Modal.Branch.

Overlay

Renders the accessibility-hidden backdrop. Clicking it closes with reason "backdropClick" unless Root or this Overlay disables backdrop dismissal. Overlay and Content must be siblings; Atom rejects Content nested beneath the Overlay's aria-hidden subtree. Bubbled clicks from Overlay descendants are not backdrop clicks and do not dismiss the Drawer. Separate portals are valid when the committed Content DOM is outside Overlay.

PropTypeDefault
disabledbooleanfalse
ARIA attributeValues
aria-hidden"true"
Data attributeValues
[data-slot]"drawer-overlay"
[data-state]"open" | "closed"
[data-positioned]Present after the opening frames

Content

Renders the focus-trapped panel as a div. placement is copied to a data attribute only; it does not apply positioning.

PropTypeDefault
placementstring-
aria-labelstring-
aria-labelledbystringgenerated from Title when present
aria-describedbystring | undefinedgenerated from Description when present
ariaLabelstringcompatibility fallback
initialFocusModalFocusTarget<ModalInitialFocusDetails>interaction-aware default
finalFocusModalFocusTarget<ModalFinalFocusDetails>prior focus, then Trigger
ARIA attributeValues
role"dialog"
aria-modal"true" while open
aria-hidden"true" while retained only for exit presence
inertPresent while retained only for exit presence
aria-labelExplicit native value, otherwise ariaLabel compatibility value
aria-labelledbyExplicit native value, otherwise registered Title ID
aria-describedbyExplicit native value, otherwise registered Description ID
Data attributeValues
[data-slot]"drawer-content"
[data-state]"open" | "closed"
[data-placement]Consumer-provided placement string
[data-positioned]Present after the opening frames

With keepMounted, closed Content remains inside a hidden, aria-hidden wrapper and preserves its class name and placement metadata.

Exit-animated Content retained after close immediately loses aria-modal and becomes inert and accessibility-hidden. Background isolation, focus ownership, and active scroll containment end when open becomes false, not when the visual exit ends.

Title

Provides the heading referenced by Content. It renders an h2 by default.

PropTypeDefault
as"h1" | "h2" | "h3" | "h4" | "h5" | "h6""h2"
Data attributeValues
[data-slot]"drawer-title"

Description

Provides supporting text referenced by Content. It renders a native p.

Data attributeValues
[data-slot]"drawer-description"

Close

Closes Root with reason "closeClick". It renders a native button by default and supports custom composition.

PropTypeDefault
asChildbooleanfalse
renderRenderProp-
Data attributeValues
[data-slot]"drawer-close"

The Drawer entry point also exports useModalContext and useModalContent for advanced custom modal parts. The namespaced parts are preferred for ordinary drawers because they provide the complete contract above.

For a consumer-owned third-party portal outside Content, use <Modal.Branch asChild> around its top-level element or configure the portal to target the Drawer Content element. Nested modal layers suspend the parent Drawer's trap, dismissal, and scroll ownership while the child is topmost. Long Drawer Content and registered portalled controls remain scrollable while background wheel/touch movement and scroll chaining are blocked.

Examples

tsx
import { Drawer } from "@flowstack-ui/atom";

export function NavigationDrawer() {
  return (
    <Drawer.Root>
      <Drawer.Trigger>Open navigation</Drawer.Trigger>
      <Drawer.Portal>
        <Drawer.Overlay />
        <Drawer.Content placement="left">
          <Drawer.Title>Navigation</Drawer.Title>
          <Drawer.Description>Choose an area of the application.</Drawer.Description>
          <nav aria-label="Primary">
            <a href="/projects">Projects</a>
            <a href="/settings">Settings</a>
          </nav>
          <Drawer.Close>Close navigation</Drawer.Close>
        </Drawer.Content>
      </Drawer.Portal>
    </Drawer.Root>
  );
}

Controlled Drawer

tsx
import { useState } from "react";
import { Drawer } from "@flowstack-ui/atom";

export function ControlledDrawer() {
  const [open, setOpen] = useState(false);

  return (
    <Drawer.Root open={open} onOpenChange={setOpen}>
      <Drawer.Trigger>Show filters</Drawer.Trigger>
      <Drawer.Portal>
        <Drawer.Overlay />
        <Drawer.Content placement="right">
          <Drawer.Title>Filters</Drawer.Title>
          <Drawer.Description>Narrow the visible results.</Drawer.Description>
          <Drawer.Close>Apply filters</Drawer.Close>
        </Drawer.Content>
      </Drawer.Portal>
    </Drawer.Root>
  );
}

Accessibility

Drawer uses the WAI-ARIA Modal Dialog pattern. Give every drawer a Title or native aria-label, and add Description when the purpose needs more explanation. ariaLabel remains a compatibility fallback; native ARIA is preferred and takes precedence. Atom omits aria-describedby when no Description is registered. Focus moves inside on open, stays within the drawer and its registered descendant portals, and restores after close. Touch opening focuses Content by default instead of immediately focusing an input; native autoFocus and explicit initialFocus take precedence. Use finalFocus for a different next workflow target or false to suppress automatic restoration. its registered descendant portals, and returns after close.

KeyDescription
EscapeCloses the topmost drawer when closeOnEscape is enabled.
TabMoves to the next focusable element in the drawer scope.
Shift+TabMoves to the previous focusable element in the drawer scope.
Enter / SpaceOpens from Trigger and activates native button controls.

Changelog

Unreleased

  • Added public Agent Knowledge for component selection, required composition, recurring mistakes, and validation.

0.20.3

  • Inherited document-only overflow locking so sticky application chrome remains anchored while Drawer is open at a nonzero page scroll position.

0.6.7

  • Inherited root/body overflow locking without fixed-body repositioning or unlock-time scroll restoration, avoiding iOS Safari browser-toolbar transitions and top-edge clipping caused by the compact address bar.

0.3.4

  • Inherited corrected scroll-lock compensation so opening or closing Drawer no longer shifts pages that use scrollbar-gutter: stable.

0.3.2

  • Inherited corrected nested-modal isolation handoff and final inert restoration from the shared Modal foundation.

0.3.1

  • Fixed exit-presence cleanup so closed Drawer Content and Overlay unmount after their CSS motion window even when no end event is emitted.

0.3.0

  • Fixed native Content ARIA precedence and optional Description relationships.
  • Added SSR-safe, hydration-stable Title and Description registration.
  • Added Content-level initialFocus and finalFocus, including touch-safe Content focus and explicit post-close workflow targets.
  • Added shared top-layer ownership and Modal.Branch support for third-party drawer portals.
  • Preserved descendant composite and nested-modal keyboard contracts through metadata-aware focus containment.
  • Added long-content and registered-portal scroll allowances with background wheel/touch containment and exact cleanup.
  • Added stack-aware background isolation and same-document HTMLElement portal-container enforcement through the shared Modal foundation.
  • Established shared modal ownership before paint; exit-present Content is inert and accessibility-hidden, nested lock handoff is uninterrupted, and unavailable Tab candidates are skipped.
  • Rejected Content nested beneath Overlay and prevented bubbled descendant clicks from being treated as backdrop dismissal.

0.2.0

  • Added shared dismissable layer Escape handling so nested overlays close before the parent drawer closes.
  • Improved modal focus containment, including support for registered portalled layers owned by descendants inside the drawer.
  • Preserved className on keep-mounted hidden drawer content.

0.1.0

  • Initial Atom release.