Sheet

A side panel that slides in from the top, right, bottom, or left edge of the viewport. Built on @radix-ui/react-dialog so it ships with focus trapping, Escape-to-dismiss, and role="dialog" for free. Pick Sheet when you want a plain edge-anchored panel — for the same behaviour with swipe-to-dismiss physics and snap points, reach for Drawer instead.

Installation

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

Usage

Sheet is a compound — Root owns open state, Trigger opens it, Content is the panel (auto-portaled with its own overlay, and accepts the side prop), Header stacks Title and Description, Footer pins actions to the bottom, and Close dismisses it.

import { Sheet } from "@craft-bits/core";
 
<Sheet.Root>
  <Sheet.Trigger asChild>
    <button>Open settings</button>
  </Sheet.Trigger>
  <Sheet.Content side="right">
    <Sheet.Header>
      <Sheet.Title>Settings</Sheet.Title>
      <Sheet.Description>
        Configure how the app behaves on this device.
      </Sheet.Description>
    </Sheet.Header>
    <Sheet.Footer>
      <Sheet.Close>Done</Sheet.Close>
    </Sheet.Footer>
  </Sheet.Content>
</Sheet.Root>

Drive the open state from your own store by passing open plus onOpenChange:

const [open, setOpen] = useState(false);
 
<Sheet.Root open={open} onOpenChange={setOpen}>
  <Sheet.Content side="left">{/* ... */}</Sheet.Content>
</Sheet.Root>

Understanding the component

  1. Sheet vs. Drawer vs. Dialog. A Dialog is centered and modal; a Sheet is edge-anchored without gestures; a Drawer is edge-anchored with vaul's swipe-to-dismiss physics. Reach for Sheet when the content has a natural "out of the way" position but you do not want the mobile-sheet gesture stack — for example a desktop settings panel, a side nav rail, or an edge-anchored detail view.
  2. Compound parts. Root (state owner), Trigger (opens the sheet, accepts asChild), Overlay (backdrop scrim), Content (the panel; accepts side; auto-wraps in Portal and Overlay unless you pass withPortal={false}), Header and Footer (layout helpers), Title and Description (Radix-wired aria-labelledby and aria-describedby), Close (the dismiss trigger).
  3. The side prop lives on Content. side="right" (default) and side="left" slide in horizontally and cap their width at sm:max-w-sm. side="top" and side="bottom" stretch full-width and slide in vertically. Each side animates from its own edge — open and close use slide-in-from-* and slide-out-to-* Tailwind animation utilities keyed off the panel's data-state.
  4. Controlled or uncontrolled. Omit open and onOpenChange to let Radix manage state. Pass both to drive open/close from your own state — useful for triggering the sheet from a remote action (keyboard shortcut, async callback, deep link).
  5. asChild on Trigger and Close. Both accept asChild — drop in your own Button primitive (or a framework Link for navigation-on-close) and the sheet's event handlers merge onto it via Radix's Slot.
  6. Compose your own portal. Pass withPortal={false} to Content to skip the auto-portal and place the overlay yourself — handy when you need to mount the panel inside a specific stacking context.

Props

Sheet.Root

PropTypeDefaultDescription
openbooleanControlled open state. Pair with onOpenChange.
onOpenChange(open: boolean) => voidFires when the sheet opens or closes.
defaultOpenbooleanfalseUncontrolled initial open state.
modalbooleantrueWhen true, interaction outside the sheet is blocked.

Sheet.Trigger

PropTypeDefaultDescription
asChildbooleanfalseRender trigger props onto the child element via Radix's Slot.
classNamestringMerged onto the rendered element.

Sheet.Content

PropTypeDefaultDescription
side"top" | "right" | "bottom" | "left""right"Edge the panel slides out from.
withPortalbooleantrueAuto-wrap in Portal and Overlay. Set false to compose them yourself.
overlayClassNamestringMerged onto the auto-rendered overlay (only when withPortal is true).
classNamestringMerged onto the rendered panel.

Sheet.Header / Sheet.Footer

PropTypeDefaultDescription
classNamestringMerged onto the rendered <div>.

Sheet.Title / Sheet.Description

PropTypeDefaultDescription
classNamestringMerged onto the rendered element.

Sheet.Close

PropTypeDefaultDescription
asChildbooleanfalseRender close props onto the child element via Radix's Slot.
classNamestringMerged onto the rendered button.

Accessibility

  • Radix renders the panel as role="dialog" and traps focus inside until the sheet closes.
  • Title is wired as the panel's accessible name (aria-labelledby); Description is wired as its description (aria-describedby). Both are required by Radix — wrap them in sr-only if your visual design has none.
  • Escape and backdrop clicks dismiss the sheet (override via Radix's onEscapeKeyDown and onPointerDownOutside on Content).
  • The trigger receives focus back when the sheet closes so keyboard users return to where they were.
  • Animations honour prefers-reduced-motion via the data-[state=*]:animate-in / animate-out Tailwind classes (pair with tw-animate-css, which respects the OS setting).

Credits

  • Extracted from: algoflashcards (src/platform/ui/sheet.tsx). The original embedded a hardcoded close button + XIcon and used the project's font-heading token; craft-bits drops the auto-close button (consumers wire their own via Sheet.Close) and re-skins every surface with cb-* tokens so it themes alongside the rest of the library.
  • Built on: @radix-ui/react-dialog.