A modal panel that slides in from any edge of the screen, for navigation, filters, carts and detail views. Built on the same modal as Dialog, so it traps focus, locks scroll and restores focus on close.
SheetTrigger connects the first child (any pressable, usually a Button) to the SheetContent after it. No state or refs needed.
The body is yours to pad
SheetHeader and SheetFooter bring their own padding and borders, but the
panel itself has none, so full-bleed lists and navigation are easy. Wrap the
content between them in a div with p-5, and add
min-h-0 flex-1 overflow-y-auto when it can grow taller than the screen.
right (default) and left suit detail panels and navigation; bottom suits mobile action sheets; top suits announcements and search. Each side slides in from its own edge.
import { Button } from "@/components/ui/button";import { SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger,} from "@/components/ui/sheet";const sides = ["top", "right", "bottom", "left"] as const;export default function SheetSides() { return ( <div className="grid grid-cols-2 gap-2"> {sides.map((side) => ( <SheetTrigger key={side}> <Button variant="outline" className="capitalize"> {side} </Button> <SheetContent side={side}> <SheetHeader> <SheetTitle className="capitalize">{side} sheet</SheetTitle> <SheetDescription> This sheet slides in from the {side} edge of the screen. </SheetDescription> </SheetHeader> <p className="p-5 text-muted-foreground text-sm"> Press Esc or click the backdrop to close it. </p> </SheetContent> </SheetTrigger> ))} </div> );}
size sets the maximum width of left and right sheets from the sm breakpoint up: sm (320px), md (384px, default) and lg (576px). On phones they take 75% of the screen. top and bottom sheets are full width and size to their content.
import { Button } from "@/components/ui/button";import { SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger,} from "@/components/ui/sheet";const sizes = [ { size: "sm", width: "320px" }, { size: "md", width: "384px" }, { size: "lg", width: "576px" },] as const;export default function SheetSizes() { return ( <div className="flex gap-2"> {sizes.map(({ size, width }) => ( <SheetTrigger key={size}> <Button variant="outline" className="uppercase"> {size} </Button> <SheetContent size={size}> <SheetHeader> <SheetTitle>Size {size}</SheetTitle> <SheetDescription> {width} wide from the sm breakpoint up, and 75% of the screen on phones. </SheetDescription> </SheetHeader> </SheetContent> </SheetTrigger> ))} </div> );}
The panel is always the full height of the screen. Give the content min-h-0 flex-1 overflow-y-auto so it scrolls while the header and footer stay in place.
import { Badge } from "@/components/ui/badge";import { Button } from "@/components/ui/button";import { SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger,} from "@/components/ui/sheet";const releases = [ { version: "3.8.0", date: "Sep 24", tag: "Feature", notes: [ "Saved views can now be shared with guests.", "Added keyboard shortcuts for switching projects.", "Export dashboards as PNG.", ], }, { version: "3.7.2", date: "Sep 10", tag: "Fix", notes: [ "Fixed timezone drift in recurring reports.", "Charts no longer flicker when resizing the sidebar.", ], }, { version: "3.7.0", date: "Aug 28", tag: "Feature", notes: [ "New audit log with 90-day retention.", "Bulk-edit labels from the table view.", "SAML single sign-on for Business plans.", ], }, { version: "3.6.1", date: "Aug 14", tag: "Fix", notes: [ "Improved CSV import for files over 50 MB.", "Fixed a crash when pasting rich text into comments.", ], }, { version: "3.6.0", date: "Jul 30", tag: "Feature", notes: [ "Dark mode for embedded dashboards.", "Webhooks now retry with exponential backoff.", "Filter by custom fields in search.", ], }, { version: "3.5.0", date: "Jul 12", tag: "Feature", notes: [ "Goals: track targets against any metric.", "Slack notifications for goal milestones.", ], },];export default function SheetScrollable() { return ( <SheetTrigger> <Button variant="outline">What's new</Button> <SheetContent> <SheetHeader> <SheetTitle>Changelog</SheetTitle> <SheetDescription> Everything we shipped this quarter. </SheetDescription> </SheetHeader> <div className="min-h-0 flex-1 overflow-y-auto p-5"> <ol className="grid gap-6"> {releases.map((r) => ( <li key={r.version} className="grid gap-2"> <div className="flex items-center gap-2"> <span className="font-medium text-sm">v{r.version}</span> <Badge size="sm" color={r.tag === "Fix" ? "warning" : "info"}> {r.tag} </Badge> <span className="ml-auto text-muted-foreground text-xs"> {r.date} </span> </div> <ul className="list-disc space-y-1 pl-5 text-muted-foreground text-sm"> {r.notes.map((n) => ( <li key={n}>{n}</li> ))} </ul> </li> ))} </ol> </div> <SheetFooter> <SheetClose className="w-full">Close</SheetClose> </SheetFooter> </SheetContent> </SheetTrigger> );}
Use the render-function child, ({ close }) => …, and wrap everything in a React Aria Form with flex min-h-0 flex-1 flex-col so the footer stays at the bottom. Validation runs before onSubmit; close the sheet once the request succeeds.
import { useState } from "react";import { Form } from "react-aria-components";import { Button } from "@/components/ui/button";import { Select, SelectItem } from "@/components/ui/select";import { SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger,} from "@/components/ui/sheet";import { TextField } from "@/components/ui/text-field";import { TextareaField } from "@/components/ui/textarea";import { toast } from "@/components/ui/toast";export default function SheetForm() { const [pending, setPending] = useState(false); return ( <SheetTrigger> <Button>New issue</Button> <SheetContent size="lg"> {({ close }) => ( <Form className="flex min-h-0 flex-1 flex-col" onSubmit={async (e) => { e.preventDefault(); setPending(true); await new Promise((r) => setTimeout(r, 1000)); setPending(false); close(); toast.success("Issue ENG-482 created"); }} > <SheetHeader> <SheetTitle>Create issue</SheetTitle> <SheetDescription> Issues are added to the Engineering backlog. </SheetDescription> </SheetHeader> <div className="grid min-h-0 flex-1 content-start gap-4 overflow-y-auto p-5"> <TextField label="Title" name="title" isRequired autoFocus placeholder="Checkout button unresponsive on Safari" /> <TextareaField label="Description" name="description" rows={5} placeholder="Steps to reproduce, expected and actual behavior…" /> <div className="grid grid-cols-2 gap-4"> <Select label="Priority" name="priority" defaultSelectedKey="medium" > <SelectItem id="urgent">Urgent</SelectItem> <SelectItem id="high">High</SelectItem> <SelectItem id="medium">Medium</SelectItem> <SelectItem id="low">Low</SelectItem> </Select> <Select label="Assignee" name="assignee" isRequired placeholder="Choose" > <SelectItem id="dana">Dana Kim</SelectItem> <SelectItem id="omar">Omar Haddad</SelectItem> <SelectItem id="lucia">Lucía Ferrer</SelectItem> </Select> </div> </div> <SheetFooter> <SheetClose isDisabled={pending}>Cancel</SheetClose> <Button type="submit" isPending={pending}> Create issue </Button> </SheetFooter> </Form> )} </SheetContent> </SheetTrigger> );}
Render SheetContent without a trigger and control it with isOpen and onOpenChange. Use this to open a detail panel from a list row, a table or a keyboard shortcut. Focus returns to the row that opened it.
isDismissable={false} ignores backdrop clicks, isKeyboardDismissDisabled ignores Esc, and showCloseButton={false} hides the ✕. Reserve this for work that must finish, and always leave a visible way out once it has.
A left sheet with CheckboxGroup, Slider, RadioGroup and Switch. Edits go into a draft that's only applied on "Show results"; "Reset" restores the defaults, and the trigger shows how many filters are active.
A hamburger button that opens a left sheet with navigation links, an account footer and no header. Without a SheetTitle, the sheet is named with aria-label. Links close the sheet through close() from the render function.
Enter and exit animations are tw-animate-css slide classes keyed off these attributes (300ms in, 200ms out), so React Aria waits for them before unmounting.
className on SheetContent styles the panel: override the width with sm:max-w-md, or cap a bottom sheet with max-h-[80dvh].
The panel is fixed flex flex-col bg-popover with a border on the inner edge. The inner dialog is a relative flex h-full flex-col, so children stack from top to bottom and SheetFooter sits at the bottom. It scrolls as a whole as a fallback when you don't give the content its own scroll area.