Drawer is a slide-in side panel that wraps Spar's Dialog primitive in modal
mode. It provides a compound component API for building navigation menus, detail
views, filters, or contextual forms that overlay the current page.
Usage
import { Drawer } from '@takeoff-ui/react-spar';
<Drawer>
<Drawer.Trigger />
<Drawer.Overlay />
<Drawer.Panel>
<Drawer.Header>
<Drawer.Title />
<Drawer.Close>✕</Drawer.Close>
</Drawer.Header>
<Drawer.Body>
<Drawer.Description />
</Drawer.Body>
<Drawer.Footer />
</Drawer.Panel>
</Drawer>
Playground
function PlaygroundDemo() {
return (
<Drawer placement="right">
<Drawer.Trigger as={Button}>Open Drawer</Drawer.Trigger>
<Drawer.Overlay />
<Drawer.Panel>
<Drawer.Header>
<Drawer.Title>Flight Details</Drawer.Title>
<Drawer.Close>✕</Drawer.Close>
</Drawer.Header>
<Drawer.Body>
<Drawer.Description>
Review your selected flight information and passenger details before confirming the booking.
</Drawer.Description>
</Drawer.Body>
</Drawer.Panel>
</Drawer>
);
}
render(<PlaygroundDemo />);
Placement
The placement prop controls from which side the drawer slides in.
function PlacementDemo() {
const [placement, setPlacement] = useState('right');
return (
<div className="flex flex-wrap justify-center gap-3">
{['left', 'right', 'top', 'bottom', 'full-screen'].map((p) => (
<Drawer key={p} placement={p}>
<Drawer.Trigger as={Button}>{p}</Drawer.Trigger>
<Drawer.Overlay />
<Drawer.Panel>
<Drawer.Header>
<Drawer.Title>Drawer from {p}</Drawer.Title>
<Drawer.Close>✕</Drawer.Close>
</Drawer.Header>
<Drawer.Body>
This drawer slides in from the <strong>{p}</strong> side.
</Drawer.Body>
</Drawer.Panel>
</Drawer>
))}
</div>
);
}
render(<PlacementDemo />);
Drawer.Footer supports a few visual variants through the footerType prop.
Use divided to add a separation line or light to soften the footer
background.
function FooterTypeDrawerDemo() {
const footerTypes = ['basic', 'divided', 'light'];
return (
<div className="flex flex-wrap justify-center gap-3">
{footerTypes.map((type) => (
<Drawer key={type} placement="right">
<Drawer.Trigger as={Button}>{type}</Drawer.Trigger>
<Drawer.Overlay />
<Drawer.Panel>
<Drawer.Header>
<Drawer.Title>{type} footer</Drawer.Title>
<Drawer.Close>✕</Drawer.Close>
</Drawer.Header>
<Drawer.Body>
This footer uses <code>{type}</code> styling.
</Drawer.Body>
<Drawer.Footer footerType={type}>
<Button variant="secondary">Cancel</Button>
<Button>Confirm</Button>
</Drawer.Footer>
</Drawer.Panel>
</Drawer>
))}
</div>
);
}
render(<FooterTypeDrawerDemo />);
Drawer.Header supports multiple visual variants via the headerType prop. Use
this option to add a divider, light or dark background, or a primary-style
header when the drawer content requires extra emphasis.
function HeaderTypeDrawerDemo() {
const headerTypes = ['basic', 'divided', 'light', 'dark', 'primary'];
return (
<div className="flex flex-wrap justify-center gap-3">
{headerTypes.map((type) => (
<Drawer key={type} placement="right">
<Drawer.Trigger as={Button}>{type}</Drawer.Trigger>
<Drawer.Overlay />
<Drawer.Panel>
<Drawer.Header headerType={type}>
<Drawer.Title>{type} header</Drawer.Title>
<Drawer.Close>✕</Drawer.Close>
</Drawer.Header>
<Drawer.Body>
Header type: {type}
</Drawer.Body>
</Drawer.Panel>
</Drawer>
))}
</div>
);
}
render(<HeaderTypeDrawerDemo />);
Dismissible
Set dismissible={false} to prevent the drawer from closing when the user
clicks outside or presses Escape.
function DismissibleDrawerDemo() {
const [open, setOpen] = useState(false);
return (
<Drawer open={open} onOpenChange={setOpen} dismissible={false} placement="right">
<Drawer.Trigger as={Button}>Open non-dismissible</Drawer.Trigger>
<Drawer.Overlay />
<Drawer.Panel>
<Drawer.Header>
<Drawer.Title>Sticky Drawer</Drawer.Title>
<Drawer.Close>✕</Drawer.Close>
</Drawer.Header>
<Drawer.Body>
This drawer can only be closed using the close control.
</Drawer.Body>
</Drawer.Panel>
</Drawer>
);
}
render(<DismissibleDrawerDemo />);
Overlay Behavior
Use blur for a softened backdrop and invisible when the overlay should stay
interactive but visually disappear.
function OverlayBehaviorDemo() {
return (
<div className="flex flex-wrap justify-center gap-3">
<Drawer placement="right">
<Drawer.Trigger as={Button}>Blur</Drawer.Trigger>
<Drawer.Overlay blur />
<Drawer.Panel>
<Drawer.Header>
<Drawer.Title>Blurred overlay</Drawer.Title>
<Drawer.Close>✕</Drawer.Close>
</Drawer.Header>
<Drawer.Body>
The backdrop stays visible and adds a soft blur effect behind the panel.
</Drawer.Body>
</Drawer.Panel>
</Drawer>
<Drawer placement="right">
<Drawer.Trigger as={Button}>Invisible</Drawer.Trigger>
<Drawer.Overlay invisible />
<Drawer.Panel>
<Drawer.Header>
<Drawer.Title>Invisible overlay</Drawer.Title>
<Drawer.Close>✕</Drawer.Close>
</Drawer.Header>
<Drawer.Body>
The overlay remains mounted for interaction but is visually hidden.
</Drawer.Body>
</Drawer.Panel>
</Drawer>
</div>
);
}
render(<OverlayBehaviorDemo />);
Intensity
The intensity prop controls how dark the overlay appears.
function OverlayIntensityDemo() {
const intensities = ['lightest', 'light', 'base', 'dark', 'darkest'];
return (
<div className="flex flex-wrap justify-center gap-3">
{intensities.map((intensity) => (
<Drawer key={intensity} placement="right">
<Drawer.Trigger as={Button}>{intensity}</Drawer.Trigger>
<Drawer.Overlay intensity={intensity} />
<Drawer.Panel>
<Drawer.Header>
<Drawer.Title>{intensity}</Drawer.Title>
<Drawer.Close>✕</Drawer.Close>
</Drawer.Header>
<Drawer.Body>Overlay intensity: {intensity}</Drawer.Body>
</Drawer.Panel>
</Drawer>
))}
</div>
);
}
render(<OverlayIntensityDemo />);
Accessibility & Keyboard
- The drawer renders as a modal
role="dialog" with proper aria-labelledby
and aria-describedby relationships.
- Focus is trapped inside the panel while open and restored on close.
Drawer.Title provides the accessible label; Drawer.Description provides
the accessible description.
- When
dismissible={false}, pressing Escape and clicking outside will not
close the drawer.
| Key | Behavior |
|---|
| Escape | Closes the drawer (unless dismissible={false}). |
| Tab / Shift+Tab | Cycles focus within the panel. |
Animation
The drawer animates automatically via CSS transitions. The panel slides in from
the configured placement direction and the overlay fades in. No JavaScript
animation library is required.
- Panel:
transform 0.3s with cubic-bezier(0.4, 0, 0.2, 1)
- Overlay:
opacity 0.3s with ease-in-out
- Full-screen: Scale + opacity transition instead of slide.
API Reference
Drawer
Props
| Name | Type | Default | Description |
|---|
| children | React.ReactNode | - | Drawer parts rendered inside the root. |
| placement | DrawerPlacement | - | Side the drawer slides in from. |
| dismissible | boolean | true | Whether the drawer can be dismissed by clicking outside or pressing Escape. |
| id | string | - | Custom base ID for ARIA relationships. If not provided, one will be generated automatically. Sub-element IDs are derived as ${id}-title, ${id}-description, ${id}-content. |
| disabled | boolean | false | Disables all dialog triggers (prevents opening) |
| open | boolean | - | Controlled open state |
| defaultOpen | boolean | false | Initial open state (uncontrolled) |
Events
| Name | Type | Default | Description |
|---|
| onOpenChange | (open: boolean) => void | - | Callback when open state changes |
Drawer.Trigger
Data attributes
| Attribute | Applied when | Purpose |
|---|
| data-slot="root" | Always | Stable selector for wrapper styling on the root slot. |
Drawer.Overlay
Props
| Name | Type | Default | Description |
|---|
| invisible | boolean | false | When true, the overlay is rendered but visually invisible. |
| intensity | DrawerOverlayIntensity | 'base' | Overlay backdrop intensity. |
| blur | boolean | false | Applies backdrop blur on the overlay. |
| classNames | Partial<Record<"root", string>> | - | |
| slotProps | Partial<Record<"root", React.HTMLAttributes<HTMLElement>>> | - | |
| container | HTMLElement | null | document.body | Portal container element. Content is portaled to document.body by default. |
Data attributes
| Attribute | Applied when | Purpose |
|---|
| data-slot="root" | Always | Stable selector for wrapper styling on the root slot. |
| data-state | Always | "open" or "closed" — used for entry/exit animations. |
| data-intensity | Always | Reflects the resolved intensity prop. |
| data-invisible | When invisible is true | Indicates overlay is visually hidden but still interactive. |
| data-blur | When blur is true | Enables backdrop blur styling on the overlay. |
Drawer.Panel
Props
| Name | Type | Default | Description |
|---|
| classNames | Partial<Record<"root", string>> | - | |
| slotProps | Partial<Record<"root", React.HTMLAttributes<HTMLElement>>> | - | |
| container | HTMLElement | null | document.body | Portal container element. Content is portaled to document.body by default. |
| trapFocus | boolean | true | Enable focus trapping |
| restoreFocus | boolean | true | Restore focus on close |
| initialFocus | HTMLElement | (() => HTMLElement) | - | Element to focus on open |
| finalFocus | HTMLElement | (() => HTMLElement) | - | Element to focus on close |
Events
| Name | Type | Default | Description |
|---|
| onOpenAutoFocus | (event: Event) => void | - | Callback before auto-focus |
| onCloseAutoFocus | (event: Event) => void | - | Callback before focus restore |
| onEscapeKeyDown | (event: KeyboardEvent) => void | - | Escape key handler |
| onPointerDownOutside | (event: PointerEvent) => void | - | Outside click handler |
| onInteractOutside | (event: PointerEvent) => void | - | Outside interaction handler with preventDefault capability |
Data attributes
| Attribute | Applied when | Purpose |
|---|
| data-slot="root" | Always | Stable selector for wrapper styling on the root slot. |
| data-state | Always | "open" or "closed" — drives slide/scale animations. |
| data-placement | Always | Reflects placement for directional CSS transitions. |
| Name | Type | Default | Description |
|---|
| headerType | DrawerHeaderType | 'basic' | Type of the header. |
| classNames | Partial<Record<"root", string>> | - | |
| slotProps | Partial<Record<"root", React.HTMLAttributes<HTMLElement>>> | - | |
| Attribute | Applied when | Purpose |
|---|
| data-slot="root" | Always | Stable selector for wrapper styling on the root slot. |
| data-header-type | Always | Reflects the resolved headerType prop. |
Drawer.Title
Props
| Name | Type | Default | Description |
|---|
| classNames | Partial<Record<"root", string>> | - | |
| slotProps | Partial<Record<"root", React.HTMLAttributes<HTMLElement>>> | - | |
| level | number | 5 | Semantic heading level (1-6). Sets the rendered tag (h1-h6) and data-level for the document outline; the visual size is fixed regardless of level. |
Data attributes
| Attribute | Applied when | Purpose |
|---|
| data-slot="root" | Always | Stable selector for wrapper styling on the root slot. |
| data-level | Always | Reflects the resolved level prop for the document outline (does not change typography). |
Drawer.Description
Data attributes
| Attribute | Applied when | Purpose |
|---|
| data-slot="root" | Always | Stable selector for wrapper styling on the root slot. |
Drawer.Body
Data attributes
| Attribute | Applied when | Purpose |
|---|
| data-slot="root" | Always | Stable selector for wrapper styling on the root slot. |
| Name | Type | Default | Description |
|---|
| footerType | DrawerFooterType | 'basic' | Type of the footer. |
| classNames | Partial<Record<"root", string>> | - | |
| slotProps | Partial<Record<"root", React.HTMLAttributes<HTMLElement>>> | - | |
| Attribute | Applied when | Purpose |
|---|
| data-slot="root" | Always | Stable selector for wrapper styling on the root slot. |
| data-footer-type | Always | Reflects the resolved footerType prop. |
Drawer.Close
Data attributes
| Attribute | Applied when | Purpose |
|---|
| data-slot="root" | Always | Stable selector for wrapper styling on the root slot. |
Type Definitions
| Name | Definition |
|---|
| DrawerPlacement | 'left' | 'right' | 'top' | 'bottom' | 'full-screen' |
| DrawerOverlayIntensity | 'lightest' | 'light' | 'base' | 'dark' | 'darkest' |
| 'basic' | 'divided' | 'light' | 'dark' | 'primary' |
| 'basic' | 'divided' | 'light' |