Components
Drawer
BetaA slide-in panel that appears from the right, left, or bottom of the viewport. Used for contextual tasks such as filters, detail views, and settings — without navigating away from the current page.
Purpose
The Drawer is a sliding panel that overlays the current page from one edge of the viewport. It is designed for tasks that are contextually related to the page but require enough space to warrant a dedicated panel — filtering search results, viewing booking details, adjusting preferences, or completing a short sub-task.
Unlike a Modal, the Drawer does not demand full attention before the user can continue. The backdrop is present but the underlying page remains partially visible, signalling that the user can return to it easily.
Usage
Use a Drawer for:
- Filter panels on search results and destination listing pages
- Booking detail side panels triggered from an itinerary list
- Settings and preference panels (e.g. currency, language)
- Mobile navigation menus (use
side='left') — this is how MegaMenu implements its mobile nav - Quick-add or edit forms that do not require a full page
Do not use a Drawer for:
- Critical confirmations that must block all interaction — use Modal instead
- Content that could live inline on the page without disrupting the user's flow
- Full multi-step journeys — those belong on dedicated pages
- Content above 480px wide on mobile — consider a bottom-sheet (
side='bottom') instead
Anatomy
- 1Backdrop — Semi-transparent black overlay (bg-black/50) behind the panel. Clicking it calls onClose when closeOnBackdrop is true (the default).
- 2Panel — White slide-in container. Width set by the width prop when side is right or left. Height fills the viewport. Rendered into document.body via createPortal.
- 3Header — Contains the title and the close (X) button. Always visible, not scrollable. Separated from the body by a border-bottom.
- 4Close button — X icon in the header, top-right. Calls onClose. Always present — the Drawer does not support hiding the close button.
- 5Body — Scrollable content area that fills available height between header and footer. Accepts any ReactNode as children.
- 6Footer — Optional sticky area at the bottom of the panel, above a border-top. Typically contains primary and secondary action buttons.
Variants
Right drawer (default)
Slides in from the right. The most common configuration — used for filter panels and detail views on desktop.
const [open, setOpen] = useState(false);
<button onClick={() => setOpen(true)}>Open filters</button>
<Drawer
open={open}
onClose={() => setOpen(false)}
title="Filter results"
side="right"
footer={
<>
<Button variant="primary" fullWidth>Apply filters</Button>
<Button variant="ghost" fullWidth onClick={() => setOpen(false)}>Clear all</Button>
</>
}
>
<FilterForm />
</Drawer>
Left drawer
Slides in from the left. Commonly used for mobile navigation menus or site-level settings panels.
<Drawer
open={open}
onClose={() => setOpen(false)}
title="Menu"
side="left"
width="sm"
>
<nav>
<a href="/destinations">Destinations</a>
<a href="/holiday-types">Holiday Types</a>
<a href="/about">About Kuoni</a>
</nav>
</Drawer>
Bottom drawer
Slides up from the bottom edge. Best suited for mobile contexts where a side panel would be too narrow, or for quick-action sheets.
<Drawer
open={open}
onClose={() => setOpen(false)}
title="Sort by"
side="bottom"
>
<ul>
<li>Price: low to high</li>
<li>Price: high to low</li>
<li>Most popular</li>
</ul>
</Drawer>
States
Closed
When open={false}, the Drawer and its backdrop do not render. The portal is unmounted. No content is present in the DOM.
Opening
When open transitions from false to true, the panel animates in via a CSS translate transition (300ms ease-in-out). The backdrop fades in simultaneously. Focus moves to the first focusable element inside the panel.
Open
The panel is fully visible. The backdrop is interactive. Body scroll is locked. Focus is trapped inside the panel. Pressing Escape calls onClose.
Closing
When open transitions to false, the panel animates out. After the transition completes, the portal is unmounted and focus returns to the element that triggered the Drawer.
Width sizes
| Size | Width | Typical use |
|---|---|---|
sm | 288px | Mobile navigation, simple action sheets |
md | 320px | Filter panels, short forms (default) |
lg | 384px | Detail views, longer forms |
xl | 480px | Rich booking detail panels |
full | 100% | Full-screen mobile overlays |
The width prop applies only when side is 'right' or 'left'. When side='bottom', the panel spans the full viewport width.
Best practices
Use a Drawer for contextual tasks (filtering, viewing details) where the user benefits from seeing the page behind the panel.
A drawer keeps the user oriented — they can see the page behind it and return to it by closing.
Use a Drawer as a substitute for a Modal when you need the user to complete a required action before continuing.
A Drawer over a fully obscured page is a Modal with worse semantics and a harder dismiss target.
Place primary actions (Apply, Save, Confirm) in the footer prop so they remain visible without scrolling.
Putting the primary action in the sticky footer means it is always visible, even when the body scrolls.
Embed the primary action button inside the children (body) of a Drawer that has scrollable content.
A CTA at the bottom of a long scrollable body is easy to miss and requires the user to scroll down to find it.
Use side='bottom' for action sheets and sort/filter pickers on mobile viewports.
bottom provides a familiar sheet pattern that feels native on touch devices.
Use side='right' with width='xl' or 'full' on mobile viewports — switch to side='bottom' instead.
A right-side panel on a 375px screen leaves the content area too narrow to be usable.
Store a ref to the trigger element and return focus to it when the Drawer closes.
Returning focus to the trigger gives keyboard and screen reader users a clear sense of where they were.
Let focus fall to the document body or the top of the page when the Drawer closes.
Focus dropping to the top of the document disorients keyboard users who opened the Drawer from a button mid-page.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| open* | boolean | — | Controls whether the Drawer is visible. When false, the panel and backdrop are unmounted. The parent owns this state. |
| onClose* | () => void | — | Called when the user presses Escape, clicks the close button, or clicks the backdrop (if closeOnBackdrop is true). The parent is responsible for setting open to false. |
| side | 'right' | 'left' | 'bottom' | 'right' | The edge of the viewport the panel slides in from. |
| title | string | — | Text shown in the Drawer header. Used as the accessible name via aria-labelledby. |
| children* | ReactNode | — | The scrollable body content of the Drawer. |
| footer | ReactNode | — | Optional content rendered in a sticky footer area above the bottom border. Typically contains Button components. |
| width | 'sm' | 'md' | 'lg' | 'xl' | 'full' | 'md' | Panel width when side is right or left. sm=288px, md=320px, lg=384px, xl=480px, full=100%. Has no effect when side=bottom. |
| closeOnBackdrop | boolean | true | When true, clicking the backdrop calls onClose. Set to false when the user must complete an action inside the Drawer before dismissing. |
| className | string | — | Additional CSS classes applied to the panel element (not the backdrop). |
Accessibility
Dialog role and ARIA
The Drawer panel uses role="dialog" with aria-modal="true" and is labelled by its title:
<div
role="dialog"
aria-modal="true"
aria-labelledby="drawer-title"
>
<h2 id="drawer-title">Filter results</h2>
...
</div>
role="dialog"announces the panel to screen readers as a dialogaria-modal="true"instructs screen readers to restrict their virtual cursor to the panel's contentaria-labelledbyconnects the title element — screen readers announce the title when focus enters
Portal and document structure
The Drawer renders into document.body via createPortal. This means the panel appears outside the normal React tree in the DOM, which is intentional — it ensures the panel and backdrop always overlay all other content regardless of stacking context.
import { createPortal } from 'react-dom';
// Inside the Drawer component:
return createPortal(
<div> {/* backdrop + panel */} </div>,
document.body
);
Because of this, the Drawer cannot be rendered in a Node.js server environment (static build, SSR without a browser). Always guard with a typeof window !== 'undefined' check or use useEffect to mount the portal after hydration.
Focus management
When open becomes true:
- Focus moves to the first focusable element inside the panel (usually the close button or the first form field)
- Tab key cycles through focusable elements within the panel — focus cannot leave
When open becomes false:
- Focus returns to the element that triggered the Drawer
- Store a ref to the trigger to handle this reliably:
const triggerRef = useRef(null);
<button ref={triggerRef} onClick={() => setOpen(true)}>
Open filters
</button>
<Drawer open={open} onClose={() => setOpen(false)} title="Filters">
...
</Drawer>
Body scroll lock
When the Drawer opens, overflow-hidden is applied to document.body. This prevents the background page from scrolling while the panel is active. The style is removed when the Drawer closes.
Keyboard interaction
| Key | Action |
|---|---|
Escape | Closes the Drawer (calls onClose) |
Tab | Moves focus to the next focusable element (wraps within the panel) |
Shift + Tab | Moves focus to the previous focusable element (wraps within the panel) |
Enter / Space | Activates the focused button or link |