# Sheet

Extends the Dialog component to display content that complements the main content of the screen.

```tsx
import { Button } from "@/components/ui/button"
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import {
  Sheet,
  SheetClose,
  SheetContent,
  SheetDescription,
  SheetFooter,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
} from "@/components/ui/sheet"
import { VStack } from "@/components/ui/stack"

export default function SheetDemo() {
  return (
    <Sheet>
      <SheetTrigger render={<Button variant="outline" />}>Open</SheetTrigger>
      <SheetContent>
        <SheetHeader>
          <SheetTitle>Edit profile</SheetTitle>
          <SheetDescription>
            Make changes to your profile here. Click save when you&apos;re done.
          </SheetDescription>
        </SheetHeader>
        <VStack grow paddingInline={4}>
          <FieldGroup gap={6}>
            <Field>
              <FieldLabel>Name</FieldLabel>
              <Input id="sheet-demo-name" defaultValue="Pedro Duarte" />
            </Field>
            <Field>
              <FieldLabel>Username</FieldLabel>
              <Input id="sheet-demo-username" defaultValue="@peduarte" />
            </Field>
          </FieldGroup>
        </VStack>
        <SheetFooter>
          <Button type="submit">Save changes</Button>
          <SheetClose render={<Button variant="secondary" />}>Close</SheetClose>
        </SheetFooter>
      </SheetContent>
    </Sheet>
  )
}
```

## Installation

1. Install the following dependencies:

   ```bash
   npm install @base-ui/react @stylexjs/stylex clsx lucide-react
   ```

2. Set up the token layer and the StyleX compiler, if you have not already. See
   [Installation](https://carbon.style/docs/installation), and copy `state-styles.ts` from the same page.
   Copy `states.ts` for the shared popup surface and install the
   [Button](https://carbon.style/docs/components/button) for the close control.

3. Copy and paste the following code into your project.

   ```tsx title="components/ui/sheet.tsx"
   "use client"

   import { useRender } from "@base-ui/react/use-render"
   import type { CSSProperties } from "react"
   import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
   import * as stylex from "@stylexjs/stylex"
   import type { StyleXStyles } from "@stylexjs/stylex"
   import { clsx } from "clsx"
   import { X } from "lucide-react"

   import { Button } from "@/components/ui/button"
   import { stateStyles } from "@/lib/state-styles"
   import { breakpoints } from "@/theme/breakpoints.stylex"
   import { states } from "@/theme/states"
   import { color, duration, ease, layer, leading, shadow, space, text } from "@/theme/tokens.stylex"

   const styles = stylex.create({
     overlayHidden: { opacity: 0 },
     hidden: { transform: "var(--carbon-sheet-closed-transform)" },
     ending: {
       transitionDuration: {
         default: duration.panel,
         "@media (prefers-reduced-motion: reduce)": "0s",
       },
     },
     overlay: {
       position: "fixed",
       inset: 0,
       zIndex: layer.popup,
       backgroundColor: color.overlay,
       backdropFilter: "blur(2px)",
       transitionProperty: "opacity",
       transitionDuration: {
         default: duration.sheet,
         "@media (prefers-reduced-motion: reduce)": "0s",
       },
       transitionTimingFunction: ease.decelerate,
     },
     content: {
       position: "fixed",
       zIndex: layer.popup,
       display: "flex",
       flexDirection: "column",
       gap: space.s4,
       boxShadow: shadow.popupLifted,
       fontSize: text.sm,
       lineHeight: leading.sm,
       transitionProperty: "transform",
       transitionDuration: {
         default: duration.sheet,
         "@media (prefers-reduced-motion: reduce)": "0s",
       },
       transitionTimingFunction: ease.decelerate,
     },
     close: { position: "absolute", top: space.s2_5, right: space.s2_5 },
     header: { display: "grid", rowGap: space.s1, padding: space.s4 },
     footer: {
       display: "flex",
       flexDirection: "column",
       gap: space.s2,
       marginBlockStart: "auto",
     },
     title: { fontSize: text.base, lineHeight: leading.base, fontWeight: 500 },
     description: { color: color.mutedForeground },
   })

   const sides = stylex.create({
     top: {
       top: 0,
       insetInline: 0,
       height: "auto",
       "--carbon-sheet-closed-transform": "translate3d(0, calc(-100% - 2px), 0)",
     },
     right: {
       right: 0,
       insetBlock: 0,
       height: "100%",
       width: { default: "75%", [breakpoints.md]: "24rem" },
       "--carbon-sheet-closed-transform": "translate3d(calc(100% + 2px), 0, 0)",
     },
     bottom: {
       bottom: 0,
       insetInline: 0,
       height: "auto",
       "--carbon-sheet-closed-transform": "translate3d(0, calc(100% + 2px), 0)",
     },
     left: {
       left: 0,
       insetBlock: 0,
       height: "100%",
       width: { default: "75%", [breakpoints.md]: "24rem" },
       "--carbon-sheet-closed-transform": "translate3d(calc(-100% - 2px), 0, 0)",
     },
   })

   const sheetStyles = { content: [states.popupSurface, styles.content], sides }

   type SheetSide = keyof typeof sides

   type Overrides = { xstyle?: StyleXStyles; className?: string }
   type StateOverrides = Overrides & { style?: CSSProperties }

   function Sheet<Payload>(props: SheetPrimitive.Root.Props<Payload>) {
     return <SheetPrimitive.Root {...props} />
   }

   function SheetTrigger(props: SheetPrimitive.Trigger.Props) {
     return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
   }

   function SheetPortal(props: SheetPrimitive.Portal.Props) {
     return <SheetPrimitive.Portal {...props} />
   }

   function SheetClose(props: SheetPrimitive.Close.Props) {
     return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
   }

   type SheetOverlayProps = Omit<SheetPrimitive.Backdrop.Props, "className" | "style"> & StateOverrides

   function SheetOverlay({ xstyle, className, style, ...props }: SheetOverlayProps) {
     return (
       <SheetPrimitive.Backdrop
         data-slot="sheet-overlay"
         {...props}
         {...stateStyles(
           (state) => [
             styles.overlay,
             (state.transitionStatus === "starting" || state.transitionStatus === "ending") &&
               styles.overlayHidden,
             state.transitionStatus === "ending" && styles.ending,
             xstyle,
           ],
           { className, style },
         )}
       />
     )
   }

   type SheetContentProps = Omit<SheetPrimitive.Popup.Props, "className" | "style"> &
     StateOverrides & {
       side?: SheetSide
       showCloseButton?: boolean
       overlayProps?: SheetOverlayProps
     }

   function SheetContent({
     xstyle,
     className,
     style,
     children,
     side = "right",
     showCloseButton = true,
     overlayProps,
     ...props
   }: SheetContentProps) {
     return (
       <SheetPortal>
         <SheetOverlay {...overlayProps} />
         <SheetPrimitive.Popup
           data-slot="sheet-content"
           data-side={side}
           {...props}
           {...stateStyles(
             (state) => [
               states.popupSurface,
               styles.content,
               sides[side] ?? sides.right,
               (state.transitionStatus === "starting" || state.transitionStatus === "ending") &&
                 styles.hidden,
               state.transitionStatus === "ending" && styles.ending,
               xstyle,
             ],
             { className, style },
           )}
         >
           {children}
           {showCloseButton && (
             <SheetClose
               render={<Button variant="ghost" size="icon-sm" xstyle={styles.close} />}
               aria-label="Close"
             >
               <X />
             </SheetClose>
           )}
         </SheetPrimitive.Popup>
       </SheetPortal>
     )
   }

   function SheetHeader({
     render,
     ref,
     style,
     xstyle,
     className,
     ...props
   }: useRender.ComponentProps<"div"> & Overrides) {
     const sx = stylex.props(styles.header, xstyle)
     return useRender({
       defaultTagName: "div",
       render,
       ref,
       props: {
         "data-slot": "sheet-header",
         ...sx,
         ...props,
         className: clsx(sx.className, className),
         style: { ...sx.style, ...style },
       },
     })
   }

   function SheetFooter({
     render,
     ref,
     style,
     xstyle,
     className,
     ...props
   }: useRender.ComponentProps<"div"> & Overrides) {
     const sx = stylex.props(states.trayFooter, styles.footer, xstyle)
     return useRender({
       defaultTagName: "div",
       render,
       ref,
       props: {
         "data-slot": "sheet-footer",
         ...sx,
         ...props,
         className: clsx(sx.className, className),
         style: { ...sx.style, ...style },
       },
     })
   }

   function SheetTitle({ xstyle, className, ...props }: SheetPrimitive.Title.Props & Overrides) {
     const sx = stylex.props(styles.title, xstyle)
     return (
       <SheetPrimitive.Title
         data-slot="sheet-title"
         {...sx}
         {...props}
         className={clsx(sx.className, className)}
       />
     )
   }

   function SheetDescription({
     xstyle,
     className,
     ...props
   }: SheetPrimitive.Description.Props & Overrides) {
     const sx = stylex.props(styles.description, xstyle)
     return (
       <SheetPrimitive.Description
         data-slot="sheet-description"
         {...sx}
         {...props}
         className={clsx(sx.className, className)}
       />
     )
   }

   export {
     Sheet,
     SheetClose,
     SheetContent,
     SheetDescription,
     SheetFooter,
     SheetHeader,
     SheetOverlay,
     SheetPortal,
     SheetTitle,
     SheetTrigger,
     sheetStyles,
     type SheetSide,
   }
   ```

4. Update the import paths to match your project setup.

## Usage

```tsx
import {
  Sheet,
  SheetClose,
  SheetContent,
  SheetDescription,
  SheetFooter,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
} from "@/components/ui/sheet"
```

```tsx
<Sheet>
  <SheetTrigger render={<Button variant="outline" />}>Open</SheetTrigger>
  <SheetContent>
    <SheetHeader>
      <SheetTitle>Are you absolutely sure?</SheetTitle>
      <SheetDescription>This action cannot be undone.</SheetDescription>
    </SheetHeader>
  </SheetContent>
</Sheet>
```

## Composition

```text
Sheet
├── SheetTrigger
└── SheetContent          portal + overlay + popup
    ├── SheetHeader
    │   ├── SheetTitle
    │   └── SheetDescription
    └── SheetFooter
        └── SheetClose
```

`SheetContent` renders the portal, the overlay and the popup in one part. Reach
the overlay through `overlayProps`, or compose `SheetPortal` and `SheetOverlay`
yourself.

`SheetHeader` and `SheetFooter` carry their own padding, and the popup spaces
its children with a gap. A body between them needs only inline padding, so give
it a `VStack` with `paddingInline={4}`.

## Side

Use the `side` prop on `SheetContent` to set the edge of the screen where the
sheet appears. Values are `top`, `right`, `bottom`, or `left`.

```tsx
import * as stylex from "@stylexjs/stylex"
import { Button } from "@/components/ui/button"
import {
  Sheet,
  SheetClose,
  SheetContent,
  SheetDescription,
  SheetFooter,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
} from "@/components/ui/sheet"
import { HStack, VStack } from "@/components/ui/stack"

const sides = ["top", "right", "bottom", "left"] as const

const styles = stylex.create({
  short: { maxHeight: "50vh" },
})

export default function SheetSide() {
  return (
    <HStack gap={2} wrap="wrap" justify="center">
      {sides.map((side) => (
        <Sheet key={side}>
          <SheetTrigger render={<Button variant="outline" />}>{side}</SheetTrigger>
          <SheetContent
            side={side}
            xstyle={side === "top" || side === "bottom" ? styles.short : undefined}
          >
            <SheetHeader>
              <SheetTitle>Edit profile</SheetTitle>
              <SheetDescription>
                Make changes to your profile here. Click save when you&apos;re done.
              </SheetDescription>
            </SheetHeader>
            <VStack grow scrollable gap={2} paddingInline={4} className="no-scrollbar">
              {Array.from({ length: 10 }).map((_, index) => (
                <p key={index}>
                  Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor
                  incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud
                  exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute
                  irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla
                  pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui
                  officia deserunt mollit anim id est laborum.
                </p>
              ))}
            </VStack>
            <SheetFooter>
              <Button type="submit">Save changes</Button>
              <SheetClose render={<Button variant="secondary" />}>Cancel</SheetClose>
            </SheetFooter>
          </SheetContent>
        </Sheet>
      ))}
    </HStack>
  )
}
```

A side sheet is `75%` of the viewport width, or `24rem` from `48rem` up. A top
or bottom sheet is the height of its content. Cap it with `maxHeight` through
`xstyle`, and make the body a flex child with `grow` and `overflow-y: auto`.
`VStack`'s `scrollable` sets both.

## No close button

Use `showCloseButton={false}` on `SheetContent` to hide the close button.

```tsx
import { Button } from "@/components/ui/button"
import {
  Sheet,
  SheetContent,
  SheetDescription,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
} from "@/components/ui/sheet"

export default function SheetNoCloseButton() {
  return (
    <Sheet>
      <SheetTrigger render={<Button variant="outline" />}>Open Sheet</SheetTrigger>
      <SheetContent showCloseButton={false}>
        <SheetHeader>
          <SheetTitle>No Close Button</SheetTitle>
          <SheetDescription>
            This sheet doesn&apos;t have a close button in the top-right corner. Click outside to
            close.
          </SheetDescription>
        </SheetHeader>
      </SheetContent>
    </Sheet>
  )
}
```

## API reference

Pass `render` to replace a part's element or compose it with another component.

Every part wraps the Base UI `Dialog` part of the same name and accepts its
props. See the [Base UI reference](https://base-ui.com/react/components/dialog)
for the full list. Each styled part also takes `xstyle` and `className`.

### Sheet

| Prop                      | Type                               | Default |
| ------------------------- | ---------------------------------- | ------- |
| `open`                    | `boolean`                          | —       |
| `defaultOpen`             | `boolean`                          | `false` |
| `onOpenChange`            | `(open: boolean, details) => void` | —       |
| `modal`                   | `boolean \| "trap-focus"`          | `true`  |
| `disablePointerDismissal` | `boolean`                          | `false` |

### SheetContent

| Prop              | Type                                     | Default   |
| ----------------- | ---------------------------------------- | --------- |
| `side`            | `"top" \| "right" \| "bottom" \| "left"` | `"right"` |
| `showCloseButton` | `boolean`                                | `true`    |
| `overlayProps`    | `SheetOverlayProps`                      | —         |
| `initialFocus`    | `boolean \| RefObject \| (type) => …`    | —         |
| `finalFocus`      | `boolean \| RefObject \| (type) => …`    | —         |

### sheetStyles

The style map behind `SheetContent`, exported so the same box can be put on
another element.

| Key       | Description                                                 |
| --------- | ----------------------------------------------------------- |
| `content` | The fixed panel: the shared surface, shadow and transition  |
| `sides`   | One style per `side`, with the edge, size and closed offset |

`content` is a pair: `states.popupSurface` from `theme/states.ts` and the
panel's own box over it. The panel takes the surface's shadow a level deeper,
as the dialog does. There is no border. The shadow's hairline ring draws the edge
that faces the page, and the other three sides sit past the viewport.

### Data attributes

| Attribute                                  | On                | Effect                                       |
| ------------------------------------------ | ----------------- | -------------------------------------------- |
| `data-slot="sheet-content"`                | the popup         | Popup slot                                   |
| `data-slot="sheet-overlay"`                | the scrim         | Backdrop surface                             |
| `data-side`                                | the popup         | The `side` prop, `"right"` by default        |
| `data-starting-style`, `data-ending-style` | popup and overlay | Base UI writes them while the sheet animates |
| `data-open`, `data-closed`                 | popup and overlay | Base UI writes them                          |
