Skip to content

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.

SourceBase UIRegistry
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.json

Usage

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>
  )
}

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.

DoActions live in the pinned footer, where they're always reachable.
Don’tActions at the end of a long body scroll out of view.

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.

KeyBehavior
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" on SheetTitle when 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 a SheetClose in 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 enter easing 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.

PropTypeDefault
side

The edge the sheet slides in from.

"top" | "right" | "bottom" | "left""right"
variant

floating insets the panel 8px from the viewport and rounds every corner.

"default" | "floating""default"
showCloseButton

Renders an icon button in the top-right corner that closes the sheet.

booleantrue
initialFocus

Element to focus when it opens.

boolean | RefObject | (interaction) => HTMLElement | booleanNo default
finalFocus

Element to focus when it closes.

boolean | RefObject | (interaction) => HTMLElement | booleanNo default
className

Merged with the panel classes. Override the width of left and right sheets with w-*.

stringNo default

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.