Drawer
Present a secondary task in a panel that slides in from the screen edge.
Installation
- Install the package.
npm install @hashintel/ds-components- Import the component.
import { useState } from "react";
import { Button, Drawer } from "@hashintel/ds-components";
export const EntityHistoryButton = () => {
const [open, setOpen] = useState(false);
return (
<>
<Button iconName="clockRotateLeft" onClick={() => setOpen(true)}>
View history
</Button>
{open ? (
<Drawer onClose={() => setOpen(false)}>
<Drawer.Header title="Entity history" />
<Drawer.Body>
<p>Every change to this entity, newest first.</p>
</Drawer.Body>
</Drawer>
) : null}
</>
);
};When to use
- Do: Use Drawer for a secondary task carried out alongside the page: inspecting an entity, editing a property set, reviewing a flow run.
- Do: Use it when content is too long or structured for a Dialog — multi-section forms, history lists, previews.
- Do: Use
showBackdrop={false}when the user needs to keep reading or editing the page while the panel is open.
When not to use
- Don't: Don't use a drawer for a short, blocking decision such as a delete confirmation — use Dialog.
- Don't: Don't use one for a handful of actions anchored to a control — use Popover or Menu.
- Don't: Don't use a drawer as a navigation destination or for a workflow with many stages — navigate to a page instead.
Anatomy
<Drawer onClose={close}>
<Drawer.Header title="Title" description="Supporting text" />
<Drawer.Body>Content</Drawer.Body>
<Drawer.Footer actions={<Button>Confirm</Button>} />
</Drawer>Drawer.Body is required; Drawer.Header and Drawer.Footer are optional,
and the types enforce this order. A drawer without a header still renders the
close button in an empty header row.
Examples
Opening and closing
There is no open prop — render the drawer to open it, and remove it from the
tree in onClose.
Loading
Set loading while the drawer's data is fetched: a spinner overlays the body,
the panel announces busy to assistive technology, and body scroll locks until
it resolves.
Positions
position anchors the panel to a viewport edge: right (the default) suits
inspectors and editors, bottom suits console-style output. Left and right
drawers run the full viewport height; top and bottom drawers span its full
width.
Sizes
size sets the panel's extent along its anchored edge: the width for left
and right drawers, the height for top and bottom drawers.
Variants
variant controls how the footer attaches to the panel. partitionedFooter
(the default) sets the footer apart from the body on the panel frame — right
for a distinct action bar. plain joins header, body, and footer into one
continuous surface.
Header
Drawer.Header lays out a title, an optional iconName and description,
and actions rendered beside the close button. Give header action buttons
autoFocus="never" so they don't take the drawer's initial focus.
Footer actions
Drawer.Footer right-aligns actions and left-aligns secondaryActions. Put
the confirming action in actions and destructive or escape actions in
secondaryActions.
Custom header and footer
Pass children instead of the structured props to lay out header or footer
content yourself. A custom header isn't announced as the drawer's name, so add
an aria-label to the drawer.
Edge-to-edge body
Set withPadding={false} on Drawer.Body when the content manages its own
layout, such as a full-bleed table or list.
Dismissal
shouldCloseOn picks which built-in dismissals apply. The default
"closeButtonAndOverlay" closes on the close button, Escape, a backdrop
click, and an edge swipe; "closeButton" drops the backdrop click, protecting
unsaved input from stray clicks; "none" removes them all — your own UI must
close the drawer.
Without a backdrop
Set showBackdrop={false} for a non-modal panel: no dimmed overlay, the page
behind stays interactive, and focus is not trapped. Backdrop-click dismissal is
off since there is no backdrop to click.
Swapping drawers in place
Give drawers that occupy the same slot a shared swapKey: switching between
them replaces the panel content in place instead of sliding out and back in.
The first drawer in a slot still animates in.
import { useState } from "react";
import { Button, Drawer } from "@hashintel/ds-components";
const entities = ["Alice Chen", "Acme Corp", "Bay Area Lab"];
export const EntityInspector = () => {
const [selected, setSelected] = useState<string | null>(null);
return (
<>
{entities.map((entity) => (
<Button key={entity} onClick={() => setSelected(entity)}>
{entity}
</Button>
))}
{entities.map((entity) =>
selected === entity ? (
<Drawer
key={entity}
swapKey="entity-inspector"
showBackdrop={false}
onClose={() => setSelected(null)}
>
<Drawer.Header title={entity} description="Entity properties" />
<Drawer.Body>
<p>Properties and links for {entity}.</p>
</Drawer.Body>
</Drawer>
) : null,
)}
</>
);
};Initial and return focus
By default the drawer focuses its first tabbable element on open and returns
focus to the previously focused element on close. Pass initialFocusRef to
focus a specific control instead, and returnFocusRef when the original
element will be gone by the time the drawer closes.
import { useRef, useState } from "react";
import { Button, Drawer, TextInput } from "@hashintel/ds-components";
export const RenameWorkspaceButton = () => {
const [open, setOpen] = useState(false);
const [name, setName] = useState("Acme research");
const nameInputRef = useRef<HTMLInputElement>(null);
return (
<>
<Button onClick={() => setOpen(true)}>Rename workspace</Button>
{open ? (
<Drawer initialFocusRef={nameInputRef} onClose={() => setOpen(false)}>
<Drawer.Header title="Rename workspace" />
<Drawer.Body>
<TextInput
inputRef={nameInputRef}
value={name}
onChange={setName}
/>
</Drawer.Body>
</Drawer>
) : null}
</>
);
};Behavior
- The panel never covers the whole viewport — a slice of the opposite edge always stays clear so the page behind remains visible.
- Swipe-to-close starts only from a thin strip along the panel's inner edge, so content inside the panel stays selectable and draggable.
- Mounting a second drawer while one is open stacks it. Try to keep stacks no more than two deep — deeper nesting disorients users.
Content
- Title the drawer with the object or task it shows, 1–4 words, sentence case: "Entity history", "Edit entity type". No terminal punctuation.
- Use
descriptionfor one line of context ("Person · updated 2 hours ago"), not instructions — those belong in the body. - Footer labels follow Button content rules: verb-first, 1–3 words, with the
confirming action in
actionsand destructive or escape actions insecondaryActions.
Accessibility
Drawer implements the WAI-ARIA dialog pattern.
What Drawer provides
- Dialog semantics, with the
Drawer.Headertitleanddescriptionwired up as the accessible name and description. - With the backdrop (the default) the drawer is modal: focus is trapped inside and Escape closes it. The built-in close button is labelled "Close drawer".
- On close, focus returns to the element focused before it opened — or to
returnFocusRefwhen set. loadingsetsaria-busyon the panel and renders the spinner overlay in a polite live region.
What you must do
- Name the drawer when the header is custom or absent — pass
aria-label, since there is no title to announce. - Always render your own close action when
shouldCloseOn="none"— it removes the close button, Escape, and swipe, so without one the user is stranded. - Reserve
showBackdrop={false}for ancillary panels — focus is not trapped, so the surrounding page's tab order must still make sense.
API reference
Drawer
| Prop | Type | Description |
|---|---|---|
| size | "sm" | "md" | "lg" | "xl" default: "md" | |
| onKeyDown | KeyboardEventHandler<Element> | |
| variant | "partitionedFooter" | "plain" default: "partitionedFooter" | |
| position | "left" | "right" | "bottom" | "top" default: "right" | Which viewport edge the drawer is anchored to. |
| showBackdrop | boolean default: true | Whether to render the dimmed overlay behind the drawer. When false, clicking outside no longer closes it. |
| swapKey | string | Ties drawers that occupy the same slot together by a shared key. When one drawer opens while another with the same swapKey is still on top of the stack, the incoming drawer skips its enter animation and appears in place, so switching between them changes the panel content instead of sliding out and back in. Opening the first drawer in a slot (no sibling present) still animates in. |
| shouldCloseOn | "none" | "closeButtonAndOverlay" | "closeButton" default: "closeButtonAndOverlay" | Which dismissals the drawer offers: closeButtonAndOverlay closes on the close button, Escape, swipe, and backdrop clicks (backdrop requires showBackdrop); closeButton drops backdrop dismissal; none renders no close button and disables Escape and swipe, leaving closing entirely to your own controls. |
| loading | boolean | |
| onClose | (() => void) | |
| initialFocusRef | RefObject<HTMLElement | null> | Focused when the drawer opens, instead of the default initial focus. |
| returnFocusRef | RefObject<HTMLElement | null> | Receives focus when the drawer closes, instead of the element focused before it opened. |
Drawer.Header
| Prop | Type |
|---|---|
| title | ReactNode |
| description | ReactNode |
| iconName | "function" | "filter" | "at" | "circle" | … 137 more |
| actions | ReactNode |
Drawer.Body
| Prop | Type | Description |
|---|---|---|
| withPadding | boolean default: true | Turn padding on/off. Used when the body content controls padding itself. defaults to true. If set, you will need to apply useAvoidScrollWidthChange yourself if the content height/width can change. |
Drawer.Footer
| Prop | Type | Description |
|---|---|---|
| children | ReactNode | Replaces the footer's action layout entirely with custom content — mutually exclusive with actions/secondaryActions. |
| actions | ReactNode | The overlay's main actions — confirm, cancel — aligned to the footer's end. |
| secondaryActions | ReactNode | Supporting actions aligned to the footer's start, kept apart from the main ones. |
Styling
The preset sizes apply as maxWidth (left/right) or maxHeight (top/bottom)
on the panel, so for a custom size override those values through className.
import { css } from "@hashintel/ds-helpers/css";
const narrowPanel = css({ maxWidth: "[480px]" });
// …
<Drawer className={narrowPanel} onClose={close}>
<Drawer.Header title="Quick filters" />
<Drawer.Body>
<p>A narrower panel for a short form.</p>
</Drawer.Body>
</Drawer>;Was this page helpful?