Sheet

A panel that rises from an edge of the screen, floating clear of it, and leaves with a swipe.

'use client';

import { Button } from '@/components/ui/button';
import { Field, FieldLabel } from '@/components/ui/field';
import { Label } from '@/components/ui/label';
import { Radio, RadioGroup } from '@/components/ui/radio-group';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import { Sheet } from '@/components/panelui/sheet';

export default function SheetEvent() {
  return (
    <Sheet>
      <Sheet.Trigger render={<Button variant="outline" />}>Respond to invite</Sheet.Trigger>
      <Sheet.Content>
        <Sheet.Header
          title="Design critique"
          description="Thursday 22 October · 14:00–15:00"
        />
        <Sheet.Body className="flex flex-col gap-6">
          <RadioGroup defaultValue="going" aria-label="Your answer">
            <Label>
              <Radio value="going" /> Going
            </Label>
            <Label>
              <Radio value="maybe" /> Maybe
            </Label>
            <Label>
              <Radio value="declined" /> Can’t make it
            </Label>
          </RadioGroup>
          <Field>
            <FieldLabel>Note to Noor</FieldLabel>
            <Textarea placeholder="Running ten minutes late, start without me." />
          </Field>
          <Label className="justify-between">
            Remind me 15 minutes before
            <Switch defaultChecked />
          </Label>
        </Sheet.Body>
        <Sheet.Footer>
          <Sheet.Close render={<Button size="lg" />}>Send answer</Sheet.Close>
          <Sheet.Close render={<Button size="lg" variant="ghost" />}>Cancel</Sheet.Close>
        </Sheet.Footer>
      </Sheet.Content>
    </Sheet>
  );
}

Use it for a task that belongs to the page but should not leave it: answering an invitation, editing a profile, changing a setting. It rises from an edge, stands a few pixels clear of every side with its corners rounded, and goes back with a swipe, the Escape key or a click outside.

A sheet that needs the whole height can meet the edges instead: set size="full" or detached={false}.

Open a sheet from inside another and the first one steps back behind it. Wrap the app in Sheet.Stage and the page steps back too.

For a decision that needs an answer before anything else, use a dialog. For one question with a written answer, use Feedback.

Installation

npx shadcn@latest add https://panelui.dev/r/web/sheet.json

The source is copied into components/panelui/, where you can edit it.

npm install panelui-web

Import from panelui-web/sheet instead of @/components/panelui/sheet. Tailwind needs one line to read the package's classes, which is on the installation page.

Install the packages it uses:

npm install @base-ui/react tailwind-variants

Copy the source into components/panelui/sheet.tsx:

Usage

import { Sheet } from "@/components/panelui/sheet"
<Sheet>
  <Sheet.Trigger render={<Button />}>Edit profile</Sheet.Trigger>
  <Sheet.Content>
    <Sheet.Header title="Edit profile" description="Shown on your public page." />
    <Sheet.Body>
      <Field>
        <FieldLabel>Name</FieldLabel>
        <Input defaultValue="Noor Haddad" />
      </Field>
    </Sheet.Body>
    <Sheet.Footer>
      <Sheet.Close render={<Button />}>Save changes</Sheet.Close>
      <Sheet.Close render={<Button variant="ghost" />}>Cancel</Sheet.Close>
    </Sheet.Footer>
  </Sheet.Content>
</Sheet>

Composition

<Sheet.Stage>
  <Sheet>
    <Sheet.Trigger />
    <Sheet.Content>
      <Sheet.Header>
        <Sheet.Title />
        <Sheet.Description />
      </Sheet.Header>
      <Sheet.Body />
      <Sheet.Footer>
        <Sheet.Close />
      </Sheet.Footer>
    </Sheet.Content>
  </Sheet>
</Sheet.Stage>
  • Sheet.Trigger — What opens the sheet. A plain button, or your own through render.
  • Sheet.Content — The sheet and the backdrop behind it. It draws the grabber on a top or bottom sheet and the round close button; showGrabber and showClose turn them off. inline draws it in the page instead of over it.
  • Sheet.Header — The title and a line under it, as title and description or as Title and Description children. It leaves room for the close button on the same line.
  • Sheet.Title — The sheet's name. It is also what a screen reader announces when the sheet opens.
  • Sheet.Description — A sentence under the title, read out after it.
  • Sheet.Body — The part that scrolls. The header and footer stay where they are while it moves.
  • Sheet.Footer — The actions, stacked full width.
  • Sheet.Close — Closes the sheet, even when dismissible is off. Give it render to make your own button close it.
  • Sheet.Stage — Wraps the page. While a sheet inside it is open, the page steps back slightly and rounds its corners. Set --stage-radius to the radius of what it sits in, less the gap, so the two corners are concentric.

Examples

From any edge

side sets the edge the sheet rises from and is swiped back to. Side sheets run the full height, still clear of the edges.

'use client';

import { Button } from '@/components/ui/button';
import { Sheet, type SheetSide } from '@/components/panelui/sheet';

const SIDES: SheetSide[] = ['top', 'right', 'bottom', 'left'];

export default function SheetSides() {
  return (
    <div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
      {SIDES.map((side) => (
        <Sheet key={side} side={side}>
          <Sheet.Trigger render={<Button variant="outline" className="capitalize" />}>{side}</Sheet.Trigger>
          <Sheet.Content>
            <Sheet.Header
              title="Edit profile"
              description="Make changes to your profile here. Save when you are done."
            />
            <Sheet.Body />
            <Sheet.Footer>
              <Sheet.Close render={<Button size="lg" />}>Save changes</Sheet.Close>
            </Sheet.Footer>
          </Sheet.Content>
        </Sheet>
      ))}
    </div>
  );
}

A sheet from a sheet

Put a second Sheet inside the first one's content. It slides over from the same edge, and the first one steps back: a little smaller, pushed inward, and faded. Only the first sheet darkens the page.

'use client';

import { Button } from '@/components/ui/button';
import { Field, FieldDescription, FieldLabel } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Sheet } from '@/components/panelui/sheet';

export default function SheetNested() {
  return (
    <Sheet>
      <Sheet.Trigger render={<Button variant="outline" />}>Album settings</Sheet.Trigger>
      <Sheet.Content>
        <Sheet.Header title="Album settings" description="Shared with five people · 214 photos" />
        <Sheet.Body className="flex flex-col gap-6">
          <Field>
            <FieldLabel>Name</FieldLabel>
            <Input defaultValue="Porto, May" />
          </Field>
          <Field>
            <Label className="w-full justify-between">
              Let others add photos
              <Switch defaultChecked />
            </Label>
            <FieldDescription>Everyone in the album can upload and remove their own.</FieldDescription>
          </Field>
        </Sheet.Body>
        <Sheet.Footer>
          <Sheet.Close render={<Button size="lg" />}>Done</Sheet.Close>
          <Sheet>
            <Sheet.Trigger render={<Button size="lg" variant="destructive-outline" />}>
              Leave album
            </Sheet.Trigger>
            <Sheet.Content>
              <Sheet.Header
                title="Leave “Porto, May”?"
                description="The photos you added stay in the album. Anyone in it can invite you back."
              />
              <Sheet.Footer>
                <Sheet.Close render={<Button size="lg" variant="destructive" />}>Leave album</Sheet.Close>
                <Sheet.Close render={<Button size="lg" variant="ghost" />}>Stay</Sheet.Close>
              </Sheet.Footer>
            </Sheet.Content>
          </Sheet>
        </Sheet.Footer>
      </Sheet.Content>
    </Sheet>
  );
}

A sheet from a sheet, from any edge

Stacking works from every side. The sheet behind is pushed inward from its own edge, so a strip of it still shows.

'use client';

import { Button } from '@/components/ui/button';
import { Field, FieldLabel } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { Sheet, type SheetSide } from '@/components/panelui/sheet';

const SIDES: SheetSide[] = ['top', 'right', 'bottom', 'left'];

function Workspace({ side }: { side: SheetSide }) {
  return (
    <Sheet side={side}>
      <Sheet.Trigger render={<Button variant="outline" className="capitalize" />}>{side}</Sheet.Trigger>
      <Sheet.Content>
        <Sheet.Header title="Workspace" description="Northwind Studio · 12 members" />
        <Sheet.Body>
          <Field>
            <FieldLabel>Workspace name</FieldLabel>
            <Input defaultValue="Northwind Studio" />
          </Field>
        </Sheet.Body>
        <Sheet.Footer>
          {/* The second sheet comes from the same edge and the first steps back. */}
          <Sheet side={side}>
            <Sheet.Trigger render={<Button size="lg" />}>Invite people</Sheet.Trigger>
            <Sheet.Content>
              <Sheet.Header title="Invite people" description="They join as members and can be promoted later." />
              <Sheet.Body>
                <Field>
                  <FieldLabel>Email</FieldLabel>
                  <Input type="email" placeholder="name@northwind.studio" />
                </Field>
              </Sheet.Body>
              <Sheet.Footer>
                <Sheet.Close render={<Button size="lg" />}>Send invite</Sheet.Close>
              </Sheet.Footer>
            </Sheet.Content>
          </Sheet>
        </Sheet.Footer>
      </Sheet.Content>
    </Sheet>
  );
}

export default function SheetNestedSides() {
  return (
    <div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
      {SIDES.map((side) => (
        <Workspace key={side} side={side} />
      ))}
    </div>
  );
}

The page steps back

Everything inside Sheet.Stage steps back slightly while a sheet is open, so the sheet sits on a layer above the page. Here the stage sits in a frame with 32px corners, so --stage-radius is 26px.

'use client';

import { Button } from '@/components/ui/button';
import { Table, TableBody, TableCell, TableRow } from '@/components/ui/table';
import { Card } from '@/components/panelui/card';
import { Sheet } from '@/components/panelui/sheet';

const ACTIVITY = [
  { name: 'Rent', date: '1 Oct', amount: '−£1,240.00' },
  { name: 'Salary', date: '30 Sep', amount: '+£3,180.00' },
  { name: 'Grocer’s', date: '29 Sep', amount: '−£46.20' },
  { name: 'Train pass', date: '28 Sep', amount: '−£128.00' },
];

const DESTINATIONS = ['To savings', 'To a friend', 'Pay a bill', 'Top up card'];

export default function SheetStageDemo() {
  return (
    <div className="w-full max-w-sm overflow-hidden rounded-[2rem] border">
      <Sheet.Stage className="[--stage-radius:26px]">
        <div className="flex flex-col gap-3 p-3">
          <Card size="sm" className="rounded-[20px]">
            <Card.Header>
              <Card.Description>Current account</Card.Description>
              <Card.Title className="text-3xl tracking-tight">£2,914.06</Card.Title>
            </Card.Header>
            <Card.Content>
              <Table>
                <TableBody>
                  {ACTIVITY.map((row) => (
                    <TableRow key={row.name}>
                      <TableCell>{row.name}</TableCell>
                      <TableCell className="text-muted-foreground">{row.date}</TableCell>
                      <TableCell className="text-end tabular-nums">{row.amount}</TableCell>
                    </TableRow>
                  ))}
                </TableBody>
              </Table>
            </Card.Content>
          </Card>
          <Sheet>
            <Sheet.Trigger render={<Button size="lg" />}>Move money</Sheet.Trigger>
            <Sheet.Content>
              <Sheet.Header title="Move money" description="From your current account" />
              <Sheet.Footer>
                {DESTINATIONS.map((label) => (
                  <Sheet.Close key={label} render={<Button size="lg" variant="outline" />}>
                    {label}
                  </Sheet.Close>
                ))}
              </Sheet.Footer>
            </Sheet.Content>
          </Sheet>
        </div>
      </Sheet.Stage>
    </div>
  );
}

API Reference

Sheet

PropTypeDefaultDescription
openboolean—Whether the sheet is open, when the caller holds that.
onOpenChange(open: boolean) => void—Called when the sheet asks to open or close.
defaultOpenbooleanfalseWhether it starts open, when the caller does not hold it.
sideSheetSide'bottom'The edge it rises from and is swiped back to. Default bottom.
dismissiblebooleantrueWhether a click outside, a swipe or Escape closes it. Turn it off for a sheet that must be answered; Sheet.Close still works.

Sheet.Trigger

PropTypeDefaultDescription
renderReactElement—Your own element to be the trigger, such as a styled button. It is given the click handler and the attributes that tie it to the sheet.

Sheet.Content

PropTypeDefaultDescription
detachedbooleantrueStand clear of the screen edges with every corner rounded. On by default; off, the sheet meets the edges it rises from.
size'auto' | 'half' | 'full''auto'How tall a top or bottom sheet is. auto fits the content; half and full take that share of the screen, and full meets the edges.
showGrabberboolean—Show the grabber. On by default for a top or bottom sheet.
showClosebooleantrueShow the round close button in the top trailing corner. Default true.
blurbooleanfalseFrost the page behind the sheet instead of only dimming it.
inlinebooleanfalseDraw the sheet in the page instead of over it: no backdrop, no trigger, always there. For previews and for a layout that shows the sheet as part of a screen.

Sheet.Header

PropTypeDefaultDescription
titleReactNode—The heading. In an open sheet it is also what the sheet is announced as.
descriptionReactNode—A line under the heading.

Sheet.Close

PropTypeDefaultDescription
renderReactElement—Your own element to close the sheet with, such as a Cancel button. Without it, and without children, this is the round close button.
iconboolean—Draw the cross glyph. Set for you on the built-in close button.

Every part also takes the props of the element it renders, and a className.

Notes

Swiping follows the pointer, and a quick flick toward the edge closes the sheet even when it has not moved far.

dismissible={false} keeps a click outside, Escape and a swipe from closing it. Only Sheet.Close and your own open state close it then.

size applies to top and bottom sheets. Side sheets always run the full height.

Focus moves into the sheet when it opens and returns to the trigger when it closes. The page behind cannot be scrolled or focused while it is open.

With reduced motion turned on, the sheet appears and disappears without sliding.

Server Components

Sheet is a client component. In a file that starts with "use client", write its parts with a dot, as on this page. A Server Component cannot read Sheet.Trigger off the import, so each part is also exported by name: SheetTrigger is Sheet.Trigger, and so on for the others.

import { Sheet, SheetTrigger, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetBody, SheetFooter, SheetClose, SheetStage } from "@/components/panelui/sheet"

On this page