Sheet
betaA large, rounded dialog for one task, with a big heading, an optional preview tray, calm rows, and its actions placed by role.
Preview
modal
tray
Design review
Share the event
Anyone with the link can see the time and place.
avatar
Before Ava’s first day
Design team, Thursday 09:00
- Contract signed
- Laptop: ordered
- Badge and desk keys
Install
1. Add the registry to
components.json, once per app. Without it, the shadcn CLI looks up@txbrendanin a public list that txbrendan/ui does not control.npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.
npx shadcn@latest add @txbrendan/theme3. Add the component.
npx shadcn@latest add @txbrendan/sheet
Use it for
- One task that needs a few fields or a preview, like inviting members or sharing an event.
- Keep it rendered and set open. false plays the close, then removes it. {show && <Sheet />} works too, but closes at once.
- onClose runs on Esc and on a press on the backdrop. Set open to false there. The sheet never closes itself.
- Pass actions by role. main is the primary, on the right, with its icon after the label. Give it tone="brand" for the primary look. tertiary, like Cancel or Not now, is a ghost just left of it. secondary is a ghost on the left.
- footer is for what is not an action, like "3 of 40 seats used". It sits on the left.
- tray shows a preview across the top, like the card being shared. avatar puts a 32px photo left of the heading.
- Fields inside keep their own size. Pass size to them as anywhere else.
- inline draws a still picture of it in the page, with no backdrop, for docs and cards.
Avoid it for
- A yes or no question. Use AlertDialog.
- A short form with plain actions. Use Dialog.
- Content people read beside the page. A sheet hides the page behind it.
- A sheet over a sheet. Finish one task, then open the next.
Props
- title
Required. The heading. It names the sheet.
ReactNode
- subtitle
A quiet line under the heading. It describes the sheet.
ReactNode
- avatar
Who the sheet is about, like their photo. It sits left of the heading at 32px, cropped round.
ReactNode
- tray
A preview on a muted tray across the top.
ReactNode
- children
The rows.
ReactNode
- actions
The actions by role. Each SheetAction is { label, onClick?, icon?, tone?, loading?, disabled?, type?, form? }.
{ main?: SheetAction; tertiary?: SheetAction; secondary?: SheetAction }
- SheetAction.tone
The main action's color role. Pass "brand" for the primary look. tertiary and secondary stay neutral ghosts.
"neutral" | "brand" | "danger" | "success" | "warning" · default "neutral"
- SheetAction.type
"submit" sends the form named by form.
"button" | "submit" · default "button"
- footer
A note or a status, on the left of the actions.
ReactNode
- size
How wide it can grow: sm is 24rem, md is 35rem, lg is 42rem. It never gets wider than the screen.
"sm" | "md" | "lg" · default "md"
- inline
Draws it in the page's flow, with no backdrop and no focus trap.
boolean · default false
- open
false plays the close, then removes it.
boolean · default true
- onClose
Runs on Esc and on a press on the backdrop.
() => void
- className
Extra classes on the sheet. Later classes win, so className='max-w-xl' replaces the width.
string
| Prop | Type | Default | What it does |
|---|---|---|---|
| title | ReactNode | - | Required. The heading. It names the sheet. |
| subtitle | ReactNode | - | A quiet line under the heading. It describes the sheet. |
| avatar | ReactNode | - | Who the sheet is about, like their photo. It sits left of the heading at 32px, cropped round. |
| tray | ReactNode | - | A preview on a muted tray across the top. |
| children | ReactNode | - | The rows. |
| actions | { main?: SheetAction; tertiary?: SheetAction; secondary?: SheetAction } | - | The actions by role. Each SheetAction is { label, onClick?, icon?, tone?, loading?, disabled?, type?, form? }. |
| SheetAction.tone | "neutral" | "brand" | "danger" | "success" | "warning" | "neutral" | The main action's color role. Pass "brand" for the primary look. tertiary and secondary stay neutral ghosts. |
| SheetAction.type | "button" | "submit" | "button" | "submit" sends the form named by form. |
| footer | ReactNode | - | A note or a status, on the left of the actions. |
| size | "sm" | "md" | "lg" | "md" | How wide it can grow: sm is 24rem, md is 35rem, lg is 42rem. It never gets wider than the screen. |
| inline | boolean | false | Draws it in the page's flow, with no backdrop and no focus trap. |
| open | boolean | true | false plays the close, then removes it. |
| onClose | () => void | - | Runs on Esc and on a press on the backdrop. |
| className | string | - | Extra classes on the sheet. Later classes win, so className='max-w-xl' replaces the width. |
Accessibility
- Over the page it is a modal dialog named by its title and described by its subtitle.
- Focus moves onto the sheet itself when it opens, so its title is read first. Tab stays inside, even when pressed fast.
- The page behind is hidden from screen readers and does not scroll.
- Esc closes only what is on top. A picker, menu, or SmartPick inside takes Esc first.
- Focus goes back to what opened it when it closes.
- There is no X button. Give it a tertiary action like Cancel, or people on touch screens have only the backdrop to leave by.
- inline is a section named by its title, with no focus trap.
Examples
Invite members
"use client";
import { Send } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
function InviteMembers() {
const [open, setOpen] = useState(false);
return (
<>
<Button onClick={() => setOpen(true)}>Invite members</Button>
<Sheet
open={open}
onClose={() => setOpen(false)}
title="Invite members"
subtitle="They get an email with a link to join the workspace."
actions={{
main: { label: "Send invites", icon: <Send />, tone: "brand", onClick: () => setOpen(false) },
tertiary: { label: "Cancel", onClick: () => setOpen(false) },
}}
>
<p>Add their email addresses.</p>
</Sheet>
</>
);
}Send a form
<Sheet
open={open}
onClose={close}
title="Edit event"
actions={{ main: { label: "Save", tone: "brand", type: "submit", form: "event" }, tertiary: { label: "Cancel", onClick: close } }}
>
<form id="event" onSubmit={save}>…</form>
</Sheet>A still picture, with a preview
<Sheet inline title="Share the event" tray={<Card className="w-56 p-4">Design review</Card>} actions={{ main: { label: "Share", tone: "brand" } }} />