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
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
| Prop | Type | Description |
|---|---|---|
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.
| Prop | Type | Description |
|---|---|---|
asChild? | boolean |
Any other props go to the root element, like on a plain HTML element.
SheetContentThe panel, sliding in from side (default right).
| Prop | Type | Description |
|---|---|---|
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 |
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.
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.
| Prop | Type | Description |
|---|---|---|
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>;Top
<SheetContent side="top" closeLabel={t("close")}>…</SheetContent>;Bottom
<SheetContent side="bottom" closeLabel={t("close")}>…</SheetContent>;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>;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;}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>;
