Skip to content
Overlays

Sheet

A panel that slides in from an edge of the viewport — navigation, filters, detail views that do not need a route of their own.

Right Sheet

Import

import {
  Sheet,
  SheetContent,
  SheetDescription,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
} from "@mikenotthepope/substrateui"

Docking

A side rail wants to be a drawer on a phone and a column on a laptop. dockAtis that: the overlay below the breakpoint, and at or above it a rail that is simply part of the page, with no trigger and nothing to dismiss. Narrow this page’s window past lg and back to watch it change.

dockAt="lg"

Senior Platform Engineer, and the twelve people who have applied for it.

Above the breakpoint it is not a dialog, and it does not say it is

This is the part worth arguing about. A docked rail is on screen beside the content, not over it. There is nothing to dismiss, so there is no scrim and no outside press; there is nowhere for focus to be trapped out of, so there is no trap; and Escape closes nothing, because closing is not something the rail can do. A dialog role there would describe a layer that is not there, and the aria-hidden Base UI puts on the rest of the document to make that layer modal would take a page away from a screen reader that can plainly see it.

So above the breakpoint the sheet is an <aside>: a complementary landmark, named by the same SheetTitlethat named the dialog, which is also what keeps it a landmark when a page nests it inside another sectioning element. Content beside content, with a name, reachable from a screen reader’s landmark list instead of from a button.

The trigger goes further than hiding. A button that controls nothing, reporting that the nothing is collapsed, is a sentence a screen reader reads out, so at or above the breakpoint the trigger carries neither aria-expanded nor aria-controls, and is hidden and inert as well as off screen. The class is what takes it off the screen, so the layout never waits for JavaScript; the attributes are what take it out of the accessibility tree, because a control kept from a screen reader only by a stylesheet is a control that comes back the day the stylesheet does not load.

Which is also the warning on the label. complementary is a landmark, and landmarks are for content worth jumping to. dockAt earns its keep on a rail of substance: the facts about the job, beside the people who applied for it. A filter panel with three checkboxes is a sheet at every width, and should stay one.

Below the breakpoint, what the drawer keeps

Everything it had: role="dialog" with the rest of the document aria-hiddenand the page’s scroll locked, focus trapped inside and restored to the trigger on close, Escape and outside press to dismiss, and the corner close button with its sr-only label. The scrim is bg-background/80, the page washed out rather than dimmed to black, which is the right note for a panel that is a column one breakpoint up. A Sheet without dockAt keeps the black scrim it has always had.

The trigger gets the three attributes the rail earned: aria-expanded, aria-controls and aria-haspopup="dialog". aria-controls is worth a line of its own, because it now points at an element that is really in the document: the rail is rendered whenever the drawer is not, closed below the breakpoint with hidden and inert on it. A plain Sheet has nothing to point at until it opens.

Crossing the breakpoint

Docking closes an open drawer, and says so once with onOpenChange(false). Everything that was inside it is on screen, so there is nothing left for the open state to mean, and leaving it set would reopen a modal on the way back down, taking focus from wherever the reader had got to for a reason nothing on screen gave. Focus was inside the drawer, because the drawer was modal, so it moves to the rail rather than onto the body.

What the rail cannot do is keep what is inside it. The drawer is a dialog in a portal over a scrim; the rail is a column in the flow beside the content it belongs to. Those are two places in the document, and React cannot move a subtree between parents without remounting it, so a half-typed note in a rail does not survive the crossing, where the same note in a Tabs unstackAt pane does. Keep a draft in state above the sheet if it has to survive a rotation.

Between first paint and hydration

The server cannot know the viewport, so the markup it sends is the rail: a named landmark is the weaker of the two claims and is true at either width, where “modal dialog” is not. The drawer’s half goes on one commit later, once the width is known and only if it is below the breakpoint. Until then the trigger says nothing about expanding, because it does not yet know whether there is anything to expand.

The layout does not wait for any of that. Which of the two is on screen is a class, so it is right in the first frame and nothing moves when the JavaScript lands.

Composition

Sheet
├── SheetTrigger
└── SheetContent
    └── SheetHeader
        ├── SheetTitle
        └── SheetDescription

Labels

The close button is an icon, so this string is its entire accessible name. Override one instance with the labels prop on the component, or every instance at once through LabelsProvider's sheet key — the provider is how you translate the set once instead of at each call site.

PropTypeDefaultDescription
closestring"Close"Accessible name for the corner close button.

Accessibility

Built on Base UI primitives, so focus is trapped inside the sheet while open and restored to the trigger on close. Escape closes the sheet. The backdrop is marked aria-hidden.

Every sheet must have a SheetTitle. If the title should be visually hidden, wrap it in VisuallyHidden from Base UI.

API Reference

PropTypeDefaultDescription
openboolean—Controlled open state of the sheet.
onOpenChange(open: boolean) => void—Callback fired when the open state changes.
side"top" | "right" | "bottom" | "left""right"The edge of the viewport the sheet slides in from. With dockAt it must be left or right, because docking turns the sheet into a column beside the page.
dockAt"sm" | "md" | "lg" | "xl" | "2xl"—Breakpoint at or above which the sheet stops being an overlay and becomes a column in the page. Below it, the drawer. Omit it and the component is the Base UI dialog it has always been.
headingLevel2 | 3 | 4 | 5 | 62The level SheetTitle renders at, at both widths. Defaults to the h2 Base UI's dialog title already used. Ignored without dockAt.

Parts, under dockAt

PropTypeDefaultDescription
SheetTriggerhidden and inert when docked—Below the breakpoint it carries aria-expanded, aria-controls and aria-haspopup="dialog". At or above it, it carries none of the three and is hidden and inert. render still takes an element, so render={<Button />} works as it always has.
SheetCloserenders nothing when docked—Not hidden, absent. Its whole job is to close the drawer, and above the breakpoint there is no drawer for it to have a job in. The built-in corner close button goes the same way.
SheetDescriptiona paragraph when docked—A dialog is announced with its description when it opens. A landmark is not announced at all, so the docked rail does not claim one and the text is read where it sits.
SheetContent classNamestring—Lands on both forms, so a width meant only for the docked rail wants the breakpoint prefix: lg:w-96 sizes the rail and leaves the drawer at its own w-3/4. An unprefixed w-96 would narrow the drawer too.
className (function form)not available—Base UI lets className and render be functions of the part's state. A docking Sheet renders its own elements, so there is no Base UI state to call them with, and passing a function throws rather than silently dropping it. Select on data-docked instead.