Skip to contentAsheeUIAsheeUI

Drawer

A panel from the edge of the screen, for details and filters that leave the page in view.

A drawer is a panel that comes in from one edge and takes part of the screen, leaving the rest of the page visible beside it. It is the shape for context the reader consults while the page stays where it is, a record's details, a column of filters, a short form, and the Modal is the shape for a decision that should take the whole screen. The edge the panel comes from and how much of the screen it takes are what this component adds to the overlay contract.

On the platform the panel becomes the bottom sheet the platform already uses, because a side panel on a phone has no room to be one. See React Native.

Usage

import { Button, Drawer } from "@asheeui/web";
import { useState } from "react";

export function Filters() {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <>
      <Button onClick={() => setIsOpen(true)}>Filters</Button>
      <Drawer
        isOpen={isOpen}
        onClose={() => setIsOpen(false)}
        aria-labelledby="filters-title">
        <div className="p-6">
          <h2 id="filters-title">Filter results</h2>
          <p>24 products match.</p>
        </div>
      </Drawer>
    </>
  );
}

children is the panel's content. The component draws the panel, its surface, its shadow, the border on the edge it comes from and its own close control; the padding, the heading and the actions are yours. The close control sits in the top-right corner, is first in the tab order, and is named by closeLabel, "Close drawer" unless you state otherwise. The panel is a column, so a footer pushed with mt-auto sits at the bottom of a content stack that fills the height.

There is no title prop: write a heading inside the panel and let aria-labelledby name the dialog with it. className and the rest of the div attributes land on the dialog container, the full-screen wrapper that carries the role, while style and content.className land on the panel itself. Pad the panel's content rather than the wrapper.

Edge and size

placement is one of four edges, registered as right. size measures the axis the panel occupies: for left and right it is a width, sm 20rem, md 28rem, lg 36rem, and full the whole viewport, and for top and bottom the same scale is a height. The other axis is always full, so a right panel spans the height of the page and a bottom panel its width. A panel flush against an edge has no corner to round, so there is no radius option, unlike the modal's.

animated (registered true) slides the panel and fades the layer behind it; a closing panel stays mounted for the 250 ms exit that matches the animation, so a close is not a jump. animated={false} unmounts the panel as soon as it closes instead of waiting out an exit it does not play.

The panel scrolls: it is a fixed-size column with overflow-y-auto, so content taller than the space it was given scrolls inside it rather than being clipped out of reach, the page behind is locked while the drawer is open, so there is nowhere else for it to go.

Dismissal

Both layers use the same two switches, closeOnBackdropClick and closeOnEscape, both registered true, and the same two names configure them under components.drawer. The drawer's old spellings, closeOnOverlayClick and closeOnEsc, still work as deprecated aliases; prefer the shared names in new code. Escape is the reader asking to cancel; a press on the dimmed area is a click that missed as often as it is a decision, so a panel that holds typed input turns the first off and keeps the second.

The overlay is decorative, aria-hidden, out of the tab order, so a press is a pointer gesture with no keyboard route. Escape is answered by the topmost layer through a shared stack, so it works wherever focus is inside the drawer, and a drawer under an open modal does not close on the same press. Turning both switches off still leaves the close control the component draws, and any control you put in the panel.

Both switches call onClose. It is optional in the type, but a drawer without one is presented non-modally, no scroll lock, no focus trap, no aria-modal, and logs a warning, because a layer that cannot close must not hold the page. Wire it to the state that controls isOpen.

Props

The registered defaults are size md, placement right, animated true, and both dismissal switches true, registered under the deprecated spellings, though either name configures them. State them once under components.drawer and every drawer in the product follows. The panel's surface is not registered and there is no radius tier: it is the secondary background with a shadow and a border on its edge.

The two override props merge rather than replace: overlay.className is combined with the default bg-background/80 backdrop-blur-xs, and content.className with the default bg-secondary, so a class passed there adds to the surface, or replaces one utility of it, instead of dropping the whole thing. The rest of the panel's classes, the shadow, the border and the column, stay.

Accessibility

  • The dialog container is role="dialog" with aria-modal="true" and tabIndex={-1}, and it is the element that receives your attributes, the aria-modal is omitted when there is no onClose, because the drawer is then presented non-modally. The overlay is decorative, so the reader meets the drawer once rather than an overlay and then a dialog.
  • The name is the consumer's. Pass aria-label, or aria-labelledby naming the heading inside the panel; a drawer with no name is announced as "dialog" and nothing else.
  • Focus moves into the panel when it opens, and is trapped inside it while it is open; a drawer with no onClose has no trap, because it is not allowed to hold a page it cannot release. Closing returns focus to whatever held it before. Escape closes it while closeOnEscape is on, and only the topmost layer answers that press.
  • The overlay is not focusable, so the panel's own controls are the only things in the tab order while it is open. The built-in close control is the first of them, so a reader who cannot use a pointer and whose Escape is switched off still has a way out without drawing one.

Focus, the scroll lock, the portal and the two dismissal switches are the shared overlay contract; this page states only what the drawer adds to it.