component — overlays
Sheet
An edge-anchored modal panel that slides in from any side of the viewport, built on react-aria's Modal over the Dialog surface — so it carries Dialog's header, body, footer and title, a close X by default, `role="alertdialog"` for a panel that asks rather than shows, and an `overlay` prop for the scrim. Two shapes, as with Modal: a `Sheet` wrapping a trigger and a `SheetContent`, or a `SheetContent` on its own with `isOpen`/`onOpenChange` when a route rather than a trigger decides it is open. It enters and leaves in 200ms ease-in-out, and not at all under prefers-reduced-motion. Drawer is the same panel with a drag-to-dismiss gesture, and that gesture is the one question that chooses between the two: if the user should be able to throw the panel off the screen with a finger — a bottom sheet on a phone — that is a Drawer. Otherwise it is a Sheet, which is the plainer of the two and has the fuller dialog surface.
- overlay
- sheet
- panel
- side-panel
- modal
- dialog
03 examples
Right (default)
A panel that slides in from the right edge — good for detail editing.
Bottom
A panel that slides up from the bottom edge — good for filters on mobile.
Left
A navigation drawer sliding in from the left edge.
source
sheet.tsxCopy this into your project. Resolve its dependencies from the registryDependencies in the component's API entry.
"use client"
import type { DialogProps, ModalOverlayProps } from "react-aria-components"
import {
composeRenderProps,
DialogTrigger as DialogTriggerPrimitive,
Modal,
ModalOverlay,
} from "react-aria-components"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogBody,
DialogClose,
DialogCloseIcon,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/dialog"
/**
* Sheet — quebi design system
*
* A side panel that slides in from any edge of the viewport. Composes the
* Dialog surface inside a react-aria Modal/ModalOverlay. Use for filters,
* detail editing, navigation drawers, and other off-canvas content.
*
* Two shapes, as with Modal: `Sheet` is react-aria's `DialogTrigger` and takes
* a trigger plus a `SheetContent`; when state decides instead — a drawer a
* route opens, say — render `SheetContent` on its own with
* `isOpen`/`onOpenChange` and leave `Sheet` out. A `Sheet` with a single child
* puts that child in its trigger slot, which warns on every render.
*
* Surface: bg-quebi-elevated, lifted by `shadow-quebi-float`. Docked to an
* edge it is square and draws one hairline, on the side facing the page;
* floating, it is ruled all round at the surface radius.
*/
const Sheet = DialogTriggerPrimitive
interface SheetContentProps
extends Omit<ModalOverlayProps, "children">,
Pick<DialogProps, "aria-label" | "role" | "aria-labelledby" | "children"> {
closeButton?: boolean
isFloat?: boolean
side?: "top" | "bottom" | "left" | "right"
overlay?: Omit<ModalOverlayProps, "children">
}
const sideVariants: Record<string, string> = {
top: "entering:slide-in-from-top exiting:slide-out-to-top inset-x-0 top-0 border-b data-[float=true]:inset-x-2 data-[float=true]:top-2",
bottom:
"entering:slide-in-from-bottom exiting:slide-out-to-bottom inset-x-0 bottom-0 border-t data-[float=true]:inset-x-2 data-[float=true]:bottom-2",
left: "entering:slide-in-from-left exiting:slide-out-to-left-80 inset-y-0 left-0 h-auto w-3/4 overflow-y-auto border-r sm:max-w-80 data-[float=true]:inset-y-2 data-[float=true]:left-2",
right:
"entering:slide-in-from-right exiting:slide-out-to-right-80 inset-y-0 right-0 h-auto w-3/4 overflow-y-auto border-l sm:max-w-80 data-[float=true]:inset-y-2 data-[float=true]:right-2",
}
const SheetContent = ({
className,
isDismissable: isDismissableInternal,
side = "right",
role = "dialog",
closeButton = true,
isFloat = true,
overlay,
children,
// Held back from the overlay's props: the label describes the dialog, not the
// scrim. Same reasoning as ModalContent.
"aria-label": ariaLabel,
"aria-labelledby": ariaLabelledby,
...props
}: SheetContentProps) => {
const isDismissable = isDismissableInternal ?? role !== "alertdialog"
return (
<ModalOverlay
isDismissable={isDismissable}
className={composeRenderProps(overlay?.className, (resolved) =>
cn(
// Backdrop — neutral scrim + subtle blur, as ModalContent's.
"fixed start-0 top-0 z-50 size-full overflow-hidden bg-black/60 backdrop-blur-sm",
"entering:fade-in entering:animate-in exiting:fade-out exiting:animate-out duration-200 ease-in-out",
resolved,
),
)}
{...props}
>
<Modal
data-float={isFloat}
className={composeRenderProps(className, (resolved) =>
cn(
// The side variant draws the edge; the Dialog inside is told not to.
"fixed z-50 grid gap-4 border-quebi-hairline bg-quebi-elevated text-quebi-fg shadow-quebi-float",
"data-[float=true]:rounded-(--q-radius-surface) data-[float=true]:border",
"transform-gpu transition ease-in-out will-change-transform [--visual-viewport-vertical-padding:16px]",
// One length and one curve for both directions, matching Drawer's
// `0.2s easeInOut` — Drawer is the surface the report held up as the
// right feel (task #179), so it sets the tempo and the CSS overlays
// follow it. The `ease-in-out` above sets `--tw-ease` for the
// animation as well as the transition, so the two cannot drift.
"entering:fade-in entering:animate-in exiting:fade-out exiting:animate-out duration-200",
sideVariants[side],
resolved,
),
)}
>
<Dialog
className="border-0 sm:[--gutter:--spacing(6)]"
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
role={role}
>
{(values) => (
<>
{typeof children === "function" ? children(values) : children}
{closeButton && (
<DialogCloseIcon className="end-2.5 top-2.5" isDismissable={isDismissable} />
)}
</>
)}
</Dialog>
</Modal>
</ModalOverlay>
)
}
const SheetTrigger = DialogTrigger
const SheetFooter = DialogFooter
const SheetHeader = DialogHeader
const SheetTitle = DialogTitle
const SheetDescription = DialogDescription
const SheetBody = DialogBody
const SheetClose = DialogClose
export type { SheetContentProps }
export {
Sheet,
SheetBody,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
}