Sheet
A panel that slides in from the edge of the screen for longer forms, details and filters, while keeping the page underneath in context.
import { Button } from "@/components/ui/button"
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import {
Sheet,
SheetBody,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet"
import { Textarea } from "@/components/ui/textarea"
export default function SheetDemo() {
return (
<Sheet>
<SheetTrigger render={<Button variant="outline" />}>
Edit issue
</SheetTrigger>
<SheetContent>
<SheetHeader>
<SheetTitle>Edit issue</SheetTitle>
<SheetDescription>DAT-248 · Opened by Ada Lovelace</SheetDescription>
</SheetHeader>
<SheetBody>
<FieldGroup>
<Field>
<FieldLabel htmlFor="issue-title">Title</FieldLabel>
<Input
id="issue-title"
defaultValue="Dropdown closes when scrolling inside it"
/>
</Field>
<Field>
<FieldLabel htmlFor="issue-description">Description</FieldLabel>
<Textarea
id="issue-description"
rows={5}
defaultValue="On Safari 18, scrolling a long list inside the menu dismisses it. Reproducible on the settings page."
/>
<FieldDescription>Markdown is supported.</FieldDescription>
</Field>
</FieldGroup>
</SheetBody>
<SheetFooter>
<SheetClose render={<Button variant="outline" />}>Cancel</SheetClose>
<Button>Save issue</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}Installation
$ pnpm dlx shadcn@latest add https://www.prfct.dev/r/sheet.jsonUsage
import {
Sheet,
SheetBody,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet"<Sheet>
<SheetTrigger render={<Button variant="outline" />}>Edit issue</SheetTrigger>
<SheetContent>
<SheetHeader>
<SheetTitle>Edit issue</SheetTitle>
<SheetDescription>DAT-248 · Opened by Ada Lovelace</SheetDescription>
</SheetHeader>
<SheetBody>{/* scrollable content */}</SheetBody>
<SheetFooter>
<SheetClose render={<Button variant="outline" />}>Cancel</SheetClose>
<Button>Save issue</Button>
</SheetFooter>
</SheetContent>
</Sheet>A sheet is a Dialog that lives on an edge. It shares the same behavior — focus trap, scroll lock, Esc to close — and adds a three-part layout: a fixed SheetHeader, a scrolling SheetBody and a SheetFooter pinned to the bottom.
Examples
Sides
side chooses the edge: right (the default) for details and editing, left for navigation, top and bottom for short, full-width content. Sheets on the left or right are 26rem wide, never closer than 3rem to the opposite edge; sheets on the top or bottom take at most 85% of the viewport height.
import { Button } from "@/components/ui/button"
import {
Sheet,
SheetBody,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet"
const sides = [
{
side: "top",
label: "Top",
title: "Top sheet",
description:
"Slides in from the top edge and returns focus to its trigger when closed.",
},
{
side: "right",
label: "Right",
title: "Right sheet",
description:
"Slides in from the right edge and returns focus to its trigger when closed.",
},
{
side: "bottom",
label: "Bottom",
title: "Bottom sheet",
description:
"Slides in from the bottom edge and returns focus to its trigger when closed.",
},
{
side: "left",
label: "Left",
title: "Left sheet",
description:
"Slides in from the left edge and returns focus to its trigger when closed.",
},
] as const
export default function SheetSides() {
return (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{sides.map(({ side, label, title, description }) => (
<Sheet key={side}>
<SheetTrigger render={<Button variant="outline" />}>
{label}
</SheetTrigger>
<SheetContent side={side}>
<SheetHeader>
<SheetTitle>{title}</SheetTitle>
<SheetDescription>{description}</SheetDescription>
</SheetHeader>
<SheetBody>
<div className="flex h-24 items-center justify-center rounded-xl border border-dashed text-sm text-muted-foreground">
Content
</div>
</SheetBody>
</SheetContent>
</Sheet>
))}
</div>
)
}Floating
variant="floating" insets the sheet from the viewport by 8px and rounds all its corners, so it reads as a surface above the page rather than part of the window frame. It suits lightweight panels such as notifications.
import {
BellIcon,
GitPullRequestIcon,
MessageSquareIcon,
RocketIcon,
} from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Item,
ItemContent,
ItemDescription,
ItemGroup,
ItemMedia,
ItemTitle,
} from "@/components/ui/item"
import {
Sheet,
SheetBody,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet"
const notifications = [
{
icon: RocketIcon,
title: "Production deploy finished",
body: "atlas-web · 7f3a2c1 is live in 38s.",
time: "2m",
},
{
icon: GitPullRequestIcon,
title: "Grace requested your review",
body: "#1284 Add keyboard support to the date picker",
time: "18m",
},
{
icon: MessageSquareIcon,
title: "Linus replied to your comment",
body: "“Agreed — let’s ship the spring easing as the default.”",
time: "1h",
},
]
export default function SheetFloating() {
return (
<Sheet>
<SheetTrigger render={<Button variant="outline" />}>
<BellIcon data-icon="inline-start" />
Notifications
<Badge variant="brand" size="sm" className="-mr-1">
3
</Badge>
</SheetTrigger>
<SheetContent
variant="floating"
className="w-[min(24rem,calc(100%-1rem))]"
>
<SheetHeader>
<SheetTitle>Notifications</SheetTitle>
<SheetDescription>3 unread since yesterday.</SheetDescription>
</SheetHeader>
<SheetBody className="px-3">
<ItemGroup className="gap-1">
{notifications.map((notification) => (
<Item key={notification.title} size="sm" render={<a href="#" />}>
<ItemMedia variant="icon">
<notification.icon />
</ItemMedia>
<ItemContent>
<ItemTitle>{notification.title}</ItemTitle>
<ItemDescription>{notification.body}</ItemDescription>
</ItemContent>
<span className="self-start text-xs text-muted-foreground">
{notification.time}
</span>
</Item>
))}
</ItemGroup>
</SheetBody>
</SheetContent>
</Sheet>
)
}Filters
Filters are the canonical sheet: many controls, applied to a list that stays visible behind the scrim. Put the result count in the primary action so people know what they'll get before they commit.
"use client"
import * as React from "react"
import { SlidersHorizontalIcon } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
Field,
FieldContent,
FieldDescription,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSeparator,
FieldSet,
} from "@/components/ui/field"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import {
Sheet,
SheetBody,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet"
import { Slider } from "@/components/ui/slider"
import { Switch } from "@/components/ui/switch"
const statuses = ["Backlog", "In progress", "In review", "Done"]
const maxEstimate = 21
export default function SheetFilters() {
const [selected, setSelected] = React.useState(["In progress", "In review"])
const [sort, setSort] = React.useState("updated")
const [estimate, setEstimate] = React.useState(8)
const [mine, setMine] = React.useState(true)
// Sorting is not a filter; the estimate is one once it is below the maximum.
const active =
selected.length + (estimate < maxEstimate ? 1 : 0) + (mine ? 1 : 0)
return (
<Sheet>
<SheetTrigger render={<Button variant="outline" />}>
<SlidersHorizontalIcon data-icon="inline-start" />
Filters
{active > 0 && (
<Badge variant="secondary" size="sm" className="-mr-1">
{active}
</Badge>
)}
</SheetTrigger>
<SheetContent>
<SheetHeader>
<SheetTitle>Filter issues</SheetTitle>
<SheetDescription>Showing 48 of 312 issues.</SheetDescription>
</SheetHeader>
<SheetBody>
<FieldGroup className="gap-7">
<FieldSet>
<FieldLegend variant="label">Status</FieldLegend>
<FieldGroup className="gap-3">
{statuses.map((status) => {
// IDs can't contain spaces, so "In progress" becomes "status-in-progress".
const id = `status-${status.toLowerCase().replace(/\s+/g, "-")}`
return (
<Field key={status} orientation="horizontal">
<Checkbox
id={id}
checked={selected.includes(status)}
onCheckedChange={(checked) =>
setSelected((current) =>
checked
? [...current, status]
: current.filter((item) => item !== status)
)
}
/>
<FieldLabel htmlFor={id} className="font-normal">
{status}
</FieldLabel>
</Field>
)
})}
</FieldGroup>
</FieldSet>
<FieldSeparator />
<FieldSet>
<FieldLegend variant="label">Sort by</FieldLegend>
<RadioGroup value={sort} onValueChange={setSort}>
{[
["updated", "Last updated"],
["created", "Date created"],
["priority", "Priority"],
].map(([value, label]) => (
<Field key={value} orientation="horizontal">
<RadioGroupItem value={value} id={`sort-${value}`} />
<FieldLabel
htmlFor={`sort-${value}`}
className="font-normal"
>
{label}
</FieldLabel>
</Field>
))}
</RadioGroup>
</FieldSet>
<FieldSeparator />
<Field>
<div className="flex items-center justify-between">
<FieldLabel>Max estimate</FieldLabel>
<span className="text-sm text-muted-foreground tabular-nums">
{estimate} pts
</span>
</div>
<Slider
value={estimate}
onValueChange={(value) => setEstimate(value as number)}
min={1}
max={maxEstimate}
aria-label="Max estimate"
/>
</Field>
<Field orientation="horizontal">
<FieldContent>
<FieldLabel htmlFor="assigned-to-me">Assigned to me</FieldLabel>
<FieldDescription>Only show issues you own.</FieldDescription>
</FieldContent>
<Switch
id="assigned-to-me"
checked={mine}
onCheckedChange={setMine}
/>
</Field>
</FieldGroup>
</SheetBody>
<SheetFooter>
<Button
variant="ghost"
className="sm:mr-auto"
onClick={() => {
setSelected([])
setSort("updated")
setEstimate(maxEstimate)
setMine(false)
}}
>
Reset
</Button>
<SheetClose render={<Button />}>Show 48 issues</SheetClose>
</SheetFooter>
</SheetContent>
</Sheet>
)
}Navigation
On small screens, the app navigation moves into a left sheet opened from a menu button. Mark the current page with aria-current="page".
import {
BarChart3Icon,
FolderIcon,
HomeIcon,
InboxIcon,
MenuIcon,
SettingsIcon,
} from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Sheet,
SheetBody,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet"
const links = [
{ label: "Home", icon: HomeIcon, current: true },
{ label: "Inbox", icon: InboxIcon, count: 12 },
{ label: "Projects", icon: FolderIcon },
{ label: "Analytics", icon: BarChart3Icon },
{ label: "Settings", icon: SettingsIcon },
]
export default function SheetNavigation() {
return (
<Sheet>
<SheetTrigger
render={
<Button variant="outline" size="icon" aria-label="Open navigation" />
}
>
<MenuIcon />
</SheetTrigger>
<SheetContent side="left" className="w-[min(18rem,calc(100%-3rem))]">
<SheetHeader className="pb-4">
<SheetTitle>Acme Inc.</SheetTitle>
</SheetHeader>
<SheetBody className="px-3">
<nav aria-label="Main">
<ul className="flex flex-col gap-px">
{links.map((link) => (
<li key={link.label}>
<a
href="#"
aria-current={link.current ? "page" : undefined}
className="flex h-9 items-center gap-2.5 rounded-lg px-2.5 text-sm text-muted-foreground focus-ring-inset transition-colors duration-fast hover:bg-accent hover:text-foreground aria-[current=page]:bg-accent aria-[current=page]:font-medium aria-[current=page]:text-foreground"
>
<link.icon className="size-4" />
{link.label}
{link.count !== undefined && (
<Badge variant="secondary" size="sm" className="ml-auto">
{link.count}
</Badge>
)}
</a>
</li>
))}
</ul>
</nav>
</SheetBody>
</SheetContent>
</Sheet>
)
}Width
The width is a regular utility, so override it with className — for example className="w-[min(40rem,calc(100%-3rem))]" for a wide detail view.
Guidelines
When to use
- To edit or inspect an item from a list without losing your place in it.
- For forms that are too long for a dialog but don't deserve their own page.
- For secondary navigation and filters on small screens.
When not to use
- For a quick decision or a short form — use a Dialog.
- For mobile bottom panels with swipe gestures and snap points — use a Drawer.
- For permanent side navigation on large screens — use a Sidebar.
Structure
Keep the header and footer short so the body gets the space. The title names the object, the description adds one line of context (an ID, an owner, a count), and the footer holds at most two actions plus an optional Reset on the far side.
Don’t stack
Opening a dialog on top of a sheet is acceptable for confirmations. Opening a sheet on top of a sheet is not — replace the content, or move to a page.
Accessibility
A sheet is a modal dialog with role="dialog", labeled by SheetTitle and described by SheetDescription.
| Key | Behavior |
|---|---|
EnterSpace | On the trigger: opens the sheet and moves focus into it. |
Tab | Moves through the sheet's controls. Focus is trapped until it closes. |
ShiftTab | Moves focus backwards, wrapping at the start. |
Esc | Closes the sheet and returns focus to the trigger. |
- Always include a title. Use
className="sr-only"onSheetTitlewhen the design shows something else, such as a logo. - The close button (
showCloseButton, on by default) is how touch screen reader users leave. Keep it, or put aSheetClosein the footer. - Reading order. The header, body and footer follow DOM order, so the footer's actions come after the content for screen readers and keyboard users — the same order people see.
- Motion. Sheets slide in over 320ms with the
entereasing and leave in 160ms. With reduced motion, they appear in place.
API reference
Sheet
The root. Doesn't render an element. Accepts every prop of Base UI Dialog.Root: open, defaultOpen, onOpenChange, modal, disablePointerDismissal, actionsRef.
SheetContent
Renders the portal, scrim and panel.
SheetHeader, SheetBody, SheetFooter
SheetHeader holds the title and description and leaves room for the close button. SheetBody grows to fill the panel and scrolls on its own. SheetFooter is pinned to the bottom with a hairline border; it stacks on small screens and right-aligns from sm up.
SheetTitle, SheetDescription
SheetTitle renders an <h2> and names the sheet. SheetDescription renders a <p> and describes it.
SheetTrigger, SheetClose
Open and close the sheet. Both render a <button>; use render to render a prfct Button.