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 |
|---|---|---|---|
open | boolean | — | Controlled open state. |
onOpenChange | (open: boolean) => void | — | Fires when the drawer requests to change open state — Esc, backdrop click, close button, or Radix.Root.onOpenChange. |
position | 'right' | 'left' | 'top' | 'bottom' | 'right' | Which viewport edge the drawer anchors to. |
size | 'sm' | 'md' | 'lg' | 'full' | 'md' | Bounded-axis preset (width for left/right, height for top/bottom). |
variant | 'temporary' | 'persistent' | 'sticky' | 'temporary' | Modal (backdrop, focus trap, scroll lock), non-modal (coexists with page, still dismissible), or non-modal + non-dismissible. |
children | ReactNode | — | Drawer body — rendered inside the body slot. |
title | string | ReactNode | — | Optional title, rendered in the header slot. string is the 90% case; ReactNode unlocks icon + label, chip + title, etc. |
footer | ReactNode | — | Optional footer action bar, rendered below the body. |
showCloseButton | boolean | true | Show the × close button in the header. |
closeButtonPosition | 'start' | 'end' | 'end' | Position of the × within the header row. 'end' = trailing edge (standard); 'start' = leading edge (mobile / iOS-style sheets). |
onCloseClick | () => void | — | Side-effect hook that fires when the × close button is clicked. The drawer closes unconditionally afterward. |
disableBackdropClose | boolean | false (true for sticky) | Disable closing when clicking outside the drawer. Sticky variant defaults this to true; pass explicit false to override. |
disableEscapeClose | boolean | false (true for sticky) | Disable closing on Escape. Sticky variant defaults this to true; pass explicit false to opt back INTO Esc close. |
resize | boolean | false | Render a drag handle on the free edge for resizing. |
resizeKey | string | — | localStorage key for persistence (namespaced under df.drawer.<key>). |
resizeMin | number | 240 | Minimum size (px) for the bounded axis. |
resizeMax | number | 800 | Maximum size (px) for the bounded axis. |
onSizeChange | (size: number) => void | — | Fires on every commit of the resize handle (pointer-up or keyboard step). |
visibleWhen | (engine: Engine) => boolean | — | Reactive visibility predicate — same contract as every catalog primitive. |
access | AccessRequirement | — | RBAC gate — see useAccessState. |
sx | string | — | Utility-class shortcut merged onto the content slot. |
slotProps | DrawerSlotProps | — | Per-slot className overrides. |
testId | string | — | data-testid on the content wrapper. |
| 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.