Browse docs
Browse docs
An edge-anchored sliding panel — distinct from <Dialog> (which centers
on the viewport) and <Popover> (which attaches to a trigger). Drawer
anchors to one of the four viewport edges and spans the full length of
that edge. Typical uses: right-side inspector, left navigation drawer,
bottom sheet, top notification tray.
import { Drawer } from '@dashforge/tw';
<Drawer open={open} onOpenChange={setOpen} title="Edit profile">
...body content...
</Drawer>Three overlay primitives with genuinely distinct positioning + interaction models. Pick the one that matches the anchor logic of your UI, not the one that "looks similar".
| Primitive | Anchor | Sizing | Typical use |
|---|---|---|---|
| Popover | Attaches to a trigger element via floating-ui | Fits its content | Info cards, dropdowns, form-field pickers |
| Dialog | Centers on the viewport | Bounded max-w-* tier | Confirmations, focused tasks, modal forms |
| Drawer | Anchored to a viewport edge (left/right/top/bottom), full length | Bounded on the free axis, spans the fixed axis | Inspector panels, side filters, mobile nav, bottom sheets |
If you find yourself building a "Dialog with slide-in animation from the right", that's a Drawer. If you're building a "Drawer that centers with a fixed max-width", that's a Dialog. The positioning determines the primitive.
Right-side temporary drawer with title, footer, close button, and
form content. Modal — backdrop dims the page, focus is trapped inside
the panel, Esc closes.
import { useState } from 'react';
import { Button, Drawer, Stack, TextField } from '@dashforge/tw';
function EditProfile() {
const [open, setOpen] = useState(false);
return (
<>
<Button onClick={() => setOpen(true)}>Open drawer</Button>
<Drawer
open={open}
onOpenChange={setOpen}
title="Edit profile"
footer={
<>
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
<Button onClick={() => setOpen(false)}>Save changes</Button>
</>
}
>
<Stack gap={3}>
<TextField name="name" label="Full name" defaultValue="Marco Rossi" />
<TextField name="email" label="Email" defaultValue="[email protected]" />
</Stack>
</Drawer>
</>
);
}title + showCloseButton + onCloseClickThe header slot renders when title != null OR showCloseButton is
true. When both are absent, the slot doesn't render at all and the
body starts flush at the top of the drawer. Toggle the switches below to
see every combination live:
Show title
Show × close button
× at start (default: end)
onCloseClick fired: 0 timesimport { useState } from 'react';
import { Button, Drawer, Stack, Switch } from '@dashforge/tw';
function HeaderPlayground() {
const [open, setOpen] = useState(false);
const [withTitle, setWithTitle] = useState(true);
const [withCloseButton, setWithCloseButton] = useState(true);
const [closeClicks, setCloseClicks] = useState(0);
return (
<>
<Stack gap={3}>
<Stack direction="row" gap={4}>
<Switch name="wt" checked={withTitle} onCheckedChange={setWithTitle} />
<Switch name="wcb" checked={withCloseButton} onCheckedChange={setWithCloseButton} />
</Stack>
<Button onClick={() => setOpen(true)}>Open drawer</Button>
<p>onCloseClick fired: {closeClicks} times</p>
</Stack>
<Drawer
open={open}
onOpenChange={setOpen}
title={withTitle ? "Header configuration" : undefined}
showCloseButton={withCloseButton}
onCloseClick={() => setCloseClicks(n => n + 1)}
>
Body content — the header slot doesn't render when both title
and closeButton are absent.
</Drawer>
</>
);
}title is typed as string | ReactNode. A plain string is the
90% case (title="Edit profile"); pass a ReactNode when you need
richer composition (icon + label, chip + title, avatar + name, etc.).showCloseButton defaults to true. Setting it to false hides
the × button — the drawer must then be closed programmatically or via
Esc / click-outside (subject to the disable flags).closeButtonPosition (default 'end') puts the × at the trailing
edge — the industry-standard position (MUI, Chakra, shadcn all agree).
Set to 'start' for the leading-edge placement used by iOS-style
sheets and some Material Design full-screen dialogs. The button is
rendered inside a bordered box for clear affordance in both positions.onCloseClick is a side-effect hook fired when the × button is
clicked. The drawer closes regardless — it's not a gate. Use it for
logging, analytics, or last-mile cleanup. To BLOCK the close, guard
the state upstream in onOpenChange.Common minimal configurations:
{/* Chromed with title + × close button — the default */}
<Drawer open={open} onOpenChange={setOpen} title="Edit profile">
...
</Drawer>
{/* Title only, no × — programmatic close via custom button in body */}
<Drawer
open={open}
onOpenChange={setOpen}
title="Confirm changes"
showCloseButton={false}
>
<Button onClick={() => setOpen(false)}>Done</Button>
</Drawer>
{/* No header at all — body fills top-to-bottom */}
<Drawer open={open} onOpenChange={setOpen} showCloseButton={false}>
<MyOwnHeader />
</Drawer>position="left" | "right" | "top" | "bottom". Each edge gets the
appropriate slide direction on open/close (right slides in from the
right, top slides in from the top, etc.). Motion is gated on
prefers-reduced-motion — users who opt out see an instantaneous
appear.
import { useState } from 'react';
import { Button, Drawer, Stack } from '@dashforge/tw';
type Position = 'right' | 'left' | 'top' | 'bottom';
function PositionMatrix() {
const [openPosition, setOpenPosition] = useState<Position | null>(null);
const positions: Position[] = ['right', 'left', 'top', 'bottom'];
return (
<>
<Stack direction="row" gap={2} wrap>
{positions.map((p) => (
<Button key={p} variant="outline" onClick={() => setOpenPosition(p)}>
Open {p}
</Button>
))}
</Stack>
{positions.map((p) => (
<Drawer
key={p}
open={openPosition === p}
onOpenChange={(v) => setOpenPosition(v ? p : null)}
position={p}
title={`${p.charAt(0).toUpperCase()}${p.slice(1)} drawer`}
>
<p>Anchored to the <strong>{p}</strong> edge.</p>
</Drawer>
))}
</>
);
}Drag the free edge of the drawer to resize it, or tab-focus the handle
and press ← / → (Shift+Arrow for 8px steps) for keyboard resize.
resize={true} renders the handle. resizeKey is the localStorage
key — the value persists under df.drawer.<resizeKey> and rehydrates
on the next mount. resizeMin / resizeMax clamp both drag and
keyboard resize. onSizeChange fires on every commit if you want to
mirror the value into your own store.
import { useState } from 'react';
import { Button, Drawer } from '@dashforge/tw';
function ResizableInspector() {
const [open, setOpen] = useState(false);
return (
<>
<Button onClick={() => setOpen(true)}>Open resizable drawer</Button>
<Drawer
open={open}
onOpenChange={setOpen}
position="right"
title="Resize me"
resize
resizeKey="docs-resize-demo" // ← persists under df.drawer.docs-resize-demo
resizeMin={280}
resizeMax={720}
onSizeChange={(px) => console.log('width', px)}
>
Drag the thin handle on the left edge, or Tab-focus + ArrowKey
(Shift+Arrow for 8px steps).
</Drawer>
</>
);
}Persistence namespacing: the storage key is always prefixed with
df.drawer. to avoid collisions with unrelated app storage. You pass
just the suffix (resizeKey="inspector" → localStorage key
df.drawer.inspector). Pass a distinct key per drawer you want
independently persisted.
No resizeKey? Dev-only console warning — the drawer still resizes
during the session but doesn't survive a reload. Pass a key to enable
persistence.
variant="persistent" — no backdrop, no focus trap, no scroll lock.
The drawer coexists with page content instead of modally covering it.
The canonical inspector pattern: canvas / editor / list on the left,
drawer on the right, both simultaneously interactive. Esc + click-outside
still close it (Esc always works; click-outside closes because Radix
still fires onPointerDownOutside even without a rendered backdrop).
import { useState } from 'react';
import { Button, Drawer, Switch } from '@dashforge/tw';
function InspectorPersistent() {
const [open, setOpen] = useState(false);
const [setting, setSetting] = useState(false);
return (
<>
<Button onClick={() => setOpen(v => !v)}>
{open ? 'Close inspector' : 'Open inspector'}
</Button>
<Button variant="outline" onClick={() => setSetting(v => !v)}>
Toggle setting (main area)
</Button>
<Drawer
open={open}
onOpenChange={setOpen}
position="right"
variant="persistent" // ← no backdrop, no focus trap, no scroll lock
title="Inspector"
>
<Switch
name="option"
checked={setting}
onCheckedChange={setSetting}
/>
</Drawer>
</>
);
}Note that persistent drawers leave keyboard focus management to the consumer — no focus trap means Tab can escape into the surrounding page content, which is usually what you want for an inspector but occasionally surprising. Explicitly focus a specific element on open if you need attention drawn.
variant="sticky" — like persistent (non-modal, no backdrop, no
scroll lock) PLUS Esc and click-outside are ignored. The drawer stays
open until the consumer closes it programmatically (onOpenChange(false))
or the user clicks the × close button. Use for inspectors, toolbars,
or side panels that must remain anchored while the user works elsewhere
on the page.
import { useState } from 'react';
import { Button, Drawer, Typography } from '@dashforge/tw';
function StickyInspector() {
const [open, setOpen] = useState(false);
const [closeClicks, setCloseClicks] = useState(0);
return (
<>
<Button onClick={() => setOpen(true)}>Open sticky drawer</Button>
<Drawer
open={open}
onOpenChange={setOpen}
variant="sticky" // ← Esc + click-outside ignored
title="Sticky inspector"
onCloseClick={() => setCloseClicks(n => n + 1)}
>
<Typography>
Esc and click-outside do nothing here. Only the × button or
onOpenChange(false) closes this drawer.
</Typography>
</Drawer>
<p>Close clicked {closeClicks} times</p>
</>
);
}Under the hood, sticky sets the DEFAULT of disableEscapeClose and
disableBackdropClose to true. Explicit values passed by the consumer
WIN — so you can opt back INTO Esc close (or click-outside close) on
a sticky drawer:
{/* Sticky but Esc still closes — the user gets a keyboard escape hatch */}
<Drawer
open={open}
onOpenChange={setOpen}
variant="sticky"
disableEscapeClose={false} {/* ← explicit false overrides sticky default */}
title="Inspector"
>
...
</Drawer>
{/* Sticky but click-outside closes — locks Esc only */}
<Drawer
open={open}
onOpenChange={setOpen}
variant="sticky"
disableBackdropClose={false}
>
...
</Drawer>This pattern gives you the sticky API-clarity ("stays put") with the
ability to keep a single dismiss channel open. Consumers who never touch
those props get the default "fully non-dismissible" behavior.
onCloseClick — side-effect hook on the × buttonThe close button always closes the drawer (via onOpenChange(false)).
onCloseClick is a callback that fires alongside the close, useful for
analytics, logging, or last-mile cleanup that shouldn't gate the
dismissal:
<Drawer
open={open}
onOpenChange={setOpen}
onCloseClick={() => analytics.track('inspector_closed_via_x')}
title="Inspector"
>
...
</Drawer>If you need to BLOCK the close (e.g. "confirm before losing changes"),
guard onOpenChange upstream — the close button doesn't gate the state
change, so onCloseClick isn't the right hook for prevention.
Configure <Drawer> defaults application-wide via Option C.
import { patchTheme } from '@dashforge/tw-theme';
patchTheme({
components: {
Drawer: {
defaults: {
position: 'right',
size: 'md',
variant: 'temporary',
showCloseButton: true,
},
slotProps: {
content: { className: 'shadow-2xl' },
header: { className: 'bg-neutral-50' },
},
},
},
});Configurable axes (DrawerVariantProps):
| Axis | Type | Notes |
|---|---|---|
position | 'left' | 'right' | 'top' | 'bottom' | Which viewport edge the drawer anchors to. Default 'right'. |
size | 'sm' | 'md' | 'lg' | 'full' | Bounded-axis preset. full fills the viewport on the bounded axis. Default 'md'. |
variant | 'temporary' | 'persistent' | Modal (default) vs coexisting. |
showCloseButton | boolean | Render the top-right × button. Default true. |
Precedence chain (lowest → highest):
defaultVariants (position='right', size='md', variant='temporary', showCloseButton=true).theme.components.Drawer.defaults.sx — appended to the content class chain via tailwind-merge.Same chain applies to slotProps (theme slotProps → instance slotProps).
| Prop | Type | Default | Description |
|---|---|---|---|
onOpenChange | (open: boolean) => void | — | Fires when the drawer requests to change open state — Esc, backdrop click, close button, or Radix.Root.onOpenChange. Mirror of <Dialog onOpenChange>. No reason parameter — consumers that need to differentiate escape-vs-backdrop use disableEscapeClose / disableBackdropClose explicitly. |
open | boolean | — | Controlled open state. |
access | AccessRequirement | — | RBAC gate — see useAccessState. |
children | ReactNode | — | Drawer body — rendered inside the body slot. |
closeButtonPosition | 'start' | 'end' | 'end' | Where the × close button sits within the header row.- 'end' (default) — right side of the header, after the title. Standard convention across MUI, Chakra, shadcn. - 'start' — left side of the header, before the title. Used by some full-screen mobile drawers (Material Design guideline) and by iOS-style sheets where the close affordance is at the leading edge. |
disableBackdropClose | boolean | false` for `temporary` and `persistent`, `true` for `sticky | Disable closing when the user clicks outside the drawer content. Applies to variant='temporary' (backdrop click) and variant='persistent' (click outside the invisible drawer area) — variant='sticky' implicitly defaults this to true to keep the drawer open.Explicit values WIN over the sticky default: pass false to re-enable click-outside close on a sticky drawer (opt-in escape). |
disableEscapeClose | boolean | false` for `temporary` and `persistent`, `true` for `sticky | Disable closing when the user presses Escape. Explicit values WIN over the sticky default: variant='sticky' defaults this to true so the drawer stays open, but passing disableEscapeClose={false} re-enables Esc close on a sticky drawer (opt-in escape hatch). |
footer | ReactNode | — | Optional footer — action bar, rendered under the body. |
onCloseClick | () => void | — | Fires when the × close button is clicked. The drawer closes unconditionally afterward (via onOpenChange(false)) — this is a side-effect hook for logging, analytics, or last-mile cleanup. If you need to BLOCK the close, guard the state upstream in onOpenChange instead (the close button doesn't gate it). |
onOpenAutoFocus | (event: Event) => void | — | Custom auto-focus handler fired when the drawer opens. Escape hatch for consumers who need different focus semantics than the default. DEFAULT (when this prop is omitted): the drawer prevents Radix's built-in auto-focus (which lands on the first focusable child = the × close button) and focuses the Content wrapper itself instead. Screen readers still announce the dialog via aria-labelledby → title; the visible focus ring simply doesn't stick to any child on open.OVERRIDE this when: - You want to focus a specific interactive element on open (e.g. the first form field: (e) => { e.preventDefault(); firstInputRef.current?.focus() }). - You need the Radix default (auto-focus the first tabbable) — pass undefined at call site? No — pass a handler that does nothing: () => undefined. The absence of preventDefault lets Radix's default fire. - You're troubleshooting a screen-reader-specific issue (JAWS, older NVDA versions) that needs a specific focus target.The event receives the underlying FocusEvent — call event.preventDefault() to suppress Radix's default focus, then do your own focus management. |
onSizeChange | (size: number) => void | — | Optional callback that fires on every commit of the resize handle (pointer-up OR keyboard step). Useful for consumers that want to mirror the size into their own store (e.g. a Zustand slice for layout preferences). |
position | 'right' | 'left' | 'top' | 'bottom' | 'right' | Which viewport edge the drawer anchors to. Drives the translate direction on open/close and which border is visible on the panel. |
resize | boolean | false | Render a drag handle on the free edge of the drawer that lets the user resize the bounded axis (width for left/right, height for top/bottom). |
resizeKey | string | — | localStorage key for persisting the resized dimension across sessions. Stored under df.drawer.<resizeKey>. When resize=true and this is omitted, a one-time dev-warn fires and no persistence is attempted. |
resizeMax | number | 800 | Maximum size (px) for the bounded axis. |
resizeMin | number | 240 | Minimum size (px) for the bounded axis. Clamps pointermove and keyboard-driven resizes. |
showCloseButton | boolean | true | Show the × close button in the header. Mirror of Dialog's showCloseButton. |
size | 'md' | 'sm' | 'lg' | 'full' | 'md' | Bounded-axis preset (width for left/right, height for top/bottom). The full variant fills the viewport on the bounded axis. |
slotProps | DrawerSlotProps | — | Per-slot className overrides. |
sx | string | — | Utility-class shortcut merged onto the content slot. |
testId | string | — | data-testid on the content wrapper. |
title | string | ReactNode | — | Optional title — rendered in the header slot as <RadixDialog.Title>. Typed as string | ReactNode for API discoverability (a plain string is the 90% case, ReactNode unlocks full customization: icons + label, chip + title, etc.). |
variant | 'temporary' | 'persistent' | 'sticky' | 'temporary' | Three modes, each with a different dismiss contract: - 'temporary' (default) — modal. Backdrop dims the page, focus is trapped, <body> scroll is locked, Esc + backdrop click close. - 'persistent' — non-modal. No backdrop, coexists with page content, no scroll lock. Esc + click-outside still close (unless individually disabled). - 'sticky' — non-modal, non-dismissible via user gesture. Same visual as persistent but Esc + click-outside are ignored. The drawer stays open until the consumer closes it programmatically (via onOpenChange(false)) or the user clicks the × close button. Use for inspectors / toolbars that must remain anchored while the user works elsewhere on the page.Implemented via Radix's modal prop (temporary is modal={true}; both non-modal modes are modal={false}) plus the internal dismiss handlers. |
visibleWhen | (engine: Engine) => boolean | — | Reactive visibility predicate. Falsy → renders null. The predicate can freely mix engine state (via the engine argument) and outer-scope local state / context / refs — React's re-render cascade guarantees fresh values on every evaluation.## Behavior when combined with open={true}visibleWhen does NOT sync back to open state. If the predicate flips to false while open={true}: - The drawer unmounts (returns null) - onOpenChange(false) is NOT called — your open state stays true - When the predicate flips back to true, the drawer remounts with open={true} and reappears — no user gesture neededThis is the "reactive UI" pattern: visibleWhen decides IF the component participates in the tree; open decides its state WHEN it participates. The two axes are orthogonal.If you need the drawer to close (state = false) when it's hidden — for example, to also unmount its side-effects downstream — wire that into visibleWhen's upstream state via a useEffect in the consumer, or listen for the flip and call onOpenChange(false) yourself. |
| Slot | Purpose | Rendered when |
|---|---|---|
overlay | Backdrop | variant === 'temporary' |
content | The drawer panel itself (positioned + sized). sx merges here. | always |
header | Top region holding title + close button | title != null || showCloseButton |
title | The <RadixDialog.Title> element | title != null |
closeButton | The × button | showCloseButton |
body | Main scrollable content region | always |
footer | Bottom action bar | footer != null |
resizeHandle | Drag handle on the free edge | resize === true |
@radix-ui/react-dialog — the same underlying primitive
as <Dialog>. <Drawer variant="temporary"> uses modal={true};
variant="persistent" uses modal={false} (which drops focus trap,
scroll lock, backdrop, and pointer-events blocking). Same
primitive, two positioning + interaction models.onOpenChange has no reason param: consumers that need to
differentiate escape-vs-backdrop use disableEscapeClose /
disableBackdropClose explicit props. Mirror of <Dialog>.Arrow = 1px, Shift + Arrow = 8px.
Follows the same convention as <Slider> for consistency across
the catalog.df.drawer.. Consumer keys collide only within this namespace,
never with arbitrary app storage.useEffect (not the state initializer) — no window access during
SSG, no hydration mismatch. Users see the default size for one
paint on first mount, then the persisted size takes over.prefers-reduced-motion gated: users who opt out see
an instantaneous appear/disappear, no slide.visibleWhen + access are catalog-uniform: same contract as
Slider, Progress, Stepper. Falsy predicate → null; RBAC gate
hides via useAccessState.<Dialog>: if the panel doesn't need to be
edge-anchored (a confirmation, a small focused task), Dialog is the
right primitive — smaller surface, centered, no resize concern.<Popover>: if the panel is anchored to a
specific trigger element (a dropdown, a hover card), Popover is
correct — Drawer would be overkill and awkward positioning.position="bottom" mobile lands in a future ship if there's a
real dogfood demand.