Skip to content
txbrendan/ui

Sheet

beta

A 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

Saturday · 14:00 · Room 1

Design review

Design team, in person

Share the event

Anyone with the link can see the time and place.

avatar

AP

Before Ava’s first day

Design team, Thursday 09:00

  • Contract signed
  • Laptop: ordered
  • Badge and desk keys
Saved 2 min ago

Install

  1. 1. Add the registry to components.json, once per app. Without it, the shadcn CLI looks up @txbrendan in a public list that txbrendan/ui does not control.

    npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"
  2. 2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.

    npx shadcn@latest add @txbrendan/theme
  3. 3. 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

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" } }} />