Skip to content

Sheet

Edge-anchored modal panel — a "side sheet" or drawer. Built on the same RAC ModalOverlay/Modal/Dialog construction as Dialog, because a slide-in panel is a modal dialog that happens to be anchored to an edge: it gets the same focus trap, escape and overlay dismissal, scroll lock, and it announces as a dialog to assistive tech.

import { Sheet } from "@evolve-storefront/ui/sheet";
Design notes

It is a thin wrapper on purpose. RAC gets us everything but the positioning, so the value here is that there is exactly one of these rather than one per consumer — filters, mini-cart, mobile navigation and facet drawers are all this component.

Kept separate from Dialog rather than added as another position variant: a sheet's parts differ (a scrolling SheetBody between a pinned header and footer), and a shared component whose parts only apply to some of its variants is the thing that makes a primitive hard to use correctly.

Every overlay in ui is RAC. See Dialog for why a second overlay library is a correctness bug rather than a styling choice.

Preview

RightOpen in Storybook ↗

Usage

Default: anchored right.

<Sheet>
<SheetTrigger asChild>
<Button>{t("myCarts")}</Button>
</SheetTrigger>
<SheetContent closeLabel={t("close")}>
<SheetHeader>
<SheetTitle>{t("myCarts")}</SheetTitle>
<SheetDescription>{t("myCartsDescription")}</SheetDescription>
</SheetHeader>
<SheetBody>…</SheetBody>
<SheetFooter>
<SheetClose asChild>
<Button variant="secondary">{t("cancel")}</Button>
</SheetClose>
<Button onClick={createCart}>{t("newCart")}</Button>
</SheetFooter>
</SheetContent>
</Sheet>;

Props

PropTypeDescription
open?boolean

Controlled open state. Omit to let the sheet own it.

defaultOpen?boolean
onOpenChange?((open: boolean) => void)
children?ReactNode

Change the layout

Pass children to Sheet to arrange its parts yourself. Every part is exported from @evolve-storefront/ui/sheet.

<Sheet>
<SheetTrigger asChild>
<Button>{t("myCarts")}</Button>
</SheetTrigger>
<SheetContent closeLabel={t("close")}>
<SheetHeader>
<SheetTitle>{t("myCarts")}</SheetTitle>
<SheetDescription>{t("myCartsDescription")}</SheetDescription>
</SheetHeader>
<SheetBody>…</SheetBody>
<SheetFooter>
<SheetClose asChild>
<Button variant="secondary">{t("cancel")}</Button>
</SheetClose>
<Button onClick={createCart}>{t("newCart")}</Button>
</SheetFooter>
</SheetContent>
</Sheet>;
SheetBodyThe scrolling region between the pinned header and footer — a cart list is long and its checkout button must stay reachable.

overflow-y-auto is what does the work: a flex item's automatic minimum size is zero once overflow is anything but visible, so the body is already free to shrink below its content. min-h-0 is therefore redundant given that overflow, and kept only because overriding overflow from a consumer's className is a legitimate rung-1 change — without min-h-0 that override would silently push the footer off the bottom of the viewport. Measured: the layout only breaks when both are absent.

No props of its own.

SheetCloseCloses the sheet on click.
PropTypeDescription
asChild?boolean

Any other props go to the root element, like on a plain HTML element.

SheetContentThe panel, sliding in from side (default right).
PropTypeDescription
side?"top" | "bottom" | "left" | "right" | null
hideCloseButton?boolean

Hide the built-in top-right close button.

closeLabel?string
closeIcon?ReactNode

Replace the close glyph (rung 3). Defaults to the close icon.

overlayClassName?string
onClosed?(() => void)

Fires once the sheet has closed — the seam for resetting form state.

children?ReactNode

Any other props go to the root element, like on a plain HTML element.

SheetDescriptionRegisters itself so aria-describedby is only set when a description is actually rendered — a dangling reference is worse than none.

No props of its own.

SheetFooterPinned below the scrolling body.

No props of its own.

SheetHeaderPinned above the scrolling body.

No props of its own.

SheetTitleslot="title" is what RAC reads to label the dialog.

No props of its own.

SheetTriggerOpens the sheet on click.
PropTypeDescription
asChild?boolean

Any other props go to the root element, like on a plain HTML element.

Examples

Left
<SheetContent side="left" closeLabel={t("close")}>…</SheetContent>;
Open in Storybook ↗
Top
<SheetContent side="top" closeLabel={t("close")}>…</SheetContent>;
Open in Storybook ↗
Bottom
<SheetContent side="bottom" closeLabel={t("close")}>…</SheetContent>;
Open in Storybook ↗
Long ContentThe case the primitive exists for: 40 rows in the body.
// Only the body scrolls; the header and the footer actions stay pinned.
<SheetContent closeLabel={t("close")}>
<SheetHeader>…</SheetHeader>
<SheetBody>
<ul>{carts.map((cart) => <li key={cart.id}>{cart.name}</li>)}</ul>
</SheetBody>
<SheetFooter>…</SheetFooter>
</SheetContent>;
Open in Storybook ↗
Wider Via TokenA brand widens its panels with a token rather than a component change — but the token has to be set where the portalled panel can inherit it, i.e.
/* In the brand's global.css: the panel is portalled, so set it at :root. */
:root {
--sheet-width: 36rem;
}
Open in Storybook ↗
Wider Via Class NameThe per-instance escape hatch.
<SheetContent className="sm:w-[32rem]" closeLabel={t("close")}>
<SheetHeader>
<SheetTitle>{t("filters")}</SheetTitle>
</SheetHeader>
<SheetBody>…</SheetBody>
</SheetContent>;
Open in Storybook ↗