Drawer

Present a secondary task in a panel that slides in from the screen edge.

Installation

  1. Install the package.
npm install @hashintel/ds-components
  1. 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.

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.

Drawer.Footer right-aligns actions and left-aligns secondaryActions. Put the confirming action in actions and destructive or escape actions in secondaryActions.

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 description for 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 actions and destructive or escape actions in secondaryActions.

Accessibility

Drawer implements the WAI-ARIA dialog pattern.

What Drawer provides

  • Dialog semantics, with the Drawer.Header title and description wired 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 returnFocusRef when set.
  • loading sets aria-busy on 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

PropTypeDescription
size
"sm" |⁠ "md" |⁠ "lg" |⁠ "xl"
default: "md"
onKeyDown
KeyboardEventHandler<Element>
variant
"partitionedFooter" |⁠ "plain"
default: "partitionedFooter"
position
"left" |⁠ "right" |⁠ "bottom" |⁠ "top"
default: "right"
showBackdrop
boolean
default: true
swapKey
string
shouldCloseOn
"none" |⁠ "closeButtonAndOverlay" |⁠ "closeButton"
default: "closeButtonAndOverlay"
loading
boolean
onClose
(() => void)
initialFocusRef
RefObject<HTMLElement |⁠ null>
returnFocusRef
RefObject<HTMLElement |⁠ null>

Drawer.Header

PropType
title
ReactNode
description
ReactNode
iconName
"function" |⁠ "filter" |⁠ "at" |⁠ "circle" |⁠ … 137 more
actions
ReactNode

Drawer.Body

PropTypeDescription
withPadding
boolean
default: true

Drawer.Footer

PropTypeDescription
children
ReactNode
actions
ReactNode
secondaryActions
ReactNode

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?

On this page