# Sheet

A panel that slides in from an edge of the screen and keeps the page in view.

```tsx
import {
  Button,
  Field,
  Input,
  Sheet,
  SheetBody,
  SheetClose,
  SheetContent,
  SheetDescription,
  SheetFooter,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
  Textarea,
} from 'ferry-ui'

export default function SheetHero() {
  return (
    <Sheet>
      <SheetTrigger asChild>
        <Button>Edit member</Button>
      </SheetTrigger>
      <SheetContent>
        <SheetHeader>
          <SheetTitle>Edit member</SheetTitle>
          <SheetDescription>The changes apply to each project of the workspace.</SheetDescription>
        </SheetHeader>
        <SheetBody>
          <Field label="Full name">
            <Input defaultValue="Maya Chen" />
          </Field>
          <Field label="Email">
            <Input type="email" defaultValue="maya@example.com" />
          </Field>
          <Field label="Note" optional hint="Only the admins of the workspace see this note.">
            <Textarea />
          </Field>
        </SheetBody>
        <SheetFooter className="flex-row justify-end">
          <SheetClose asChild>
            <Button>Cancel</Button>
          </SheetClose>
          <SheetClose asChild>
            <Button variant="primary">Save changes</Button>
          </SheetClose>
        </SheetFooter>
      </SheetContent>
    </Sheet>
  )
}
```

## Usage guidelines

- **Keep the page in view.** Use a sheet for a detail panel, a set of filters or a long form.
- **A short task is not a sheet.** For one short form, use [Dialog](/docs/components/dialog). For a confirmation, use [Confirm Dialog](/docs/components/confirm-dialog).
- **A small panel opens from its trigger.** For a small form or a list of actions, use [Popover](/docs/components/popover) or [Dropdown Menu](/docs/components/dropdown-menu).
- **Give each sheet a title.** `SheetTitle` is the accessible name of the sheet.

## Anatomy

Import the parts. Put them together in this order.

```tsx title="Anatomy"

  Sheet,
  SheetBody,
  SheetClose,
  SheetContent,
  SheetDescription,
  SheetFooter,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
} from 'ferry-ui'

<Sheet>
  <SheetTrigger />
  <SheetContent>
    <SheetHeader>
      <SheetTitle />
      <SheetDescription />
    </SheetHeader>
    <SheetBody />
    <SheetFooter>
      <SheetClose />
    </SheetFooter>
  </SheetContent>
</Sheet>
```

| Part | Role |
| --- | --- |
| `Sheet` | Holds the open state. |
| `SheetTrigger` | Opens the sheet on a click. |
| `SheetContent` | The panel. It adds the backdrop and the close button. |
| `SheetHeader` | Holds the title and the description. |
| `SheetTitle` | The title. It is required. |
| `SheetDescription` | A short text below the title. |
| `SheetBody` | The content. It is the only part that scrolls. |
| `SheetFooter` | The actions. It stays at the bottom of the panel. |
| `SheetClose` | Closes the sheet on a click. |

With `asChild`, `SheetTrigger` and `SheetClose` give their behavior to their child element. Use it with a [Button](/docs/components/button).

## Examples

### Side

The `side` prop of `SheetContent` sets the edge that the panel comes from.

```tsx
import { Button, Sheet, SheetBody, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from 'ferry-ui'

const SIDES = ['top', 'right', 'bottom', 'left'] as const

export default function SheetSides() {
  return (
    <>
      {SIDES.map((side) => (
        <Sheet key={side}>
          <SheetTrigger asChild>
            <Button>{side}</Button>
          </SheetTrigger>
          <SheetContent side={side}>
            <SheetHeader>
              <SheetTitle>Side {side}</SheetTitle>
              <SheetDescription>The panel comes from the {side} edge of the screen.</SheetDescription>
            </SheetHeader>
            <SheetBody className="text-[13px] text-foreground-light">The page stays in view behind the panel.</SheetBody>
          </SheetContent>
        </Sheet>
      ))}
    </>
  )
}
```

| Side | Use |
| --- | --- |
| `right` | A detail panel or an edit panel (default). |
| `left` | A navigation drawer. For the app navigation on a phone, use [Mobile Nav](/docs/components/mobile-nav). |
| `bottom` | A list of actions on a phone. |
| `top` | An announcement or a search. It is rare. |

### Width

A left or right sheet is 384px wide at most. Pass a class such as `sm:max-w-lg` to `SheetContent` to make it wider.

```tsx
import {
  Button,
  DescriptionItem,
  DescriptionList,
  Sheet,
  SheetBody,
  SheetContent,
  SheetDescription,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
} from 'ferry-ui'

export default function SheetWidth() {
  return (
    <Sheet>
      <SheetTrigger asChild>
        <Button>View order</Button>
      </SheetTrigger>
      {/* `sm:max-w-lg` makes the panel wider than the default width. */}
      <SheetContent className="sm:max-w-lg">
        <SheetHeader>
          <SheetTitle>Order #10482</SheetTitle>
          <SheetDescription>Acme placed this order on March 3, 2026.</SheetDescription>
        </SheetHeader>
        <SheetBody>
          <DescriptionList variant="rows" divided={false} aria-label="Order details">
            <DescriptionItem label="Customer">Acme</DescriptionItem>
            <DescriptionItem label="Payment">Card that ends in 4242</DescriptionItem>
            <DescriptionItem label="Delivery">Express, 2 days</DescriptionItem>
            <DescriptionItem label="Total">$1,284.00</DescriptionItem>
          </DescriptionList>
        </SheetBody>
      </SheetContent>
    </Sheet>
  )
}
```

### Footer

`SheetFooter` stacks its buttons. Add `className="flex-row justify-end"` to put them in one row.

```tsx
import {
  Button,
  Sheet,
  SheetBody,
  SheetClose,
  SheetContent,
  SheetDescription,
  SheetFooter,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
} from 'ferry-ui'

const FOOTERS = [
  // No class: the footer stacks its buttons.
  { label: 'Stacked buttons', className: undefined },
  // `flex-row justify-end` puts the buttons in one row, at the right.
  { label: 'Buttons in one row', className: 'flex-row justify-end' },
]

export default function SheetFooterLayouts() {
  return (
    <>
      {FOOTERS.map((footer) => (
        <Sheet key={footer.label}>
          <SheetTrigger asChild>
            <Button>{footer.label}</Button>
          </SheetTrigger>
          <SheetContent>
            <SheetHeader>
              <SheetTitle>Export invoices</SheetTitle>
              <SheetDescription>The export has the invoices of March 2026.</SheetDescription>
            </SheetHeader>
            <SheetBody className="text-[13px] text-foreground-light">You get an email with a link to the CSV file.</SheetBody>
            <SheetFooter className={footer.className}>
              <SheetClose asChild>
                <Button>Cancel</Button>
              </SheetClose>
              <SheetClose asChild>
                <Button variant="primary">Export</Button>
              </SheetClose>
            </SheetFooter>
          </SheetContent>
        </Sheet>
      ))}
    </>
  )
}
```

### Long content

Put long content in `SheetBody`. The body scrolls. The header and the footer stay in view.

```tsx
import {
  Button,
  Sheet,
  SheetBody,
  SheetContent,
  SheetDescription,
  SheetFooter,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
} from 'ferry-ui'

const MEMBERS = ['Maya Chen', 'Sam Lee', 'Ada Park']
const EVENTS = ['changed the billing address', 'invited a new member', 'renamed a project', 'exported the invoices']

const ACTIVITY = Array.from({ length: 24 }, (_, index) => ({
  id: index,
  who: MEMBERS[index % MEMBERS.length],
  what: EVENTS[index % EVENTS.length],
  when: `${index + 2} min ago`,
}))

export default function SheetScroll() {
  return (
    <Sheet>
      <SheetTrigger asChild>
        <Button>Show activity</Button>
      </SheetTrigger>
      <SheetContent>
        <SheetHeader>
          <SheetTitle>Activity</SheetTitle>
          <SheetDescription>The last changes in this workspace.</SheetDescription>
        </SheetHeader>
        {/* `gap-0 p-0` lets the list touch the edges of the panel. */}
        <SheetBody className="gap-0 p-0">
          <ul className="divide-y">
            {ACTIVITY.map((item) => (
              <li key={item.id} className="flex flex-col gap-0.5 px-4 py-2.5">
                <span className="text-[13px] text-foreground">
                  <span className="font-medium">{item.who}</span> {item.what}
                </span>
                <span className="text-xs text-foreground-lighter">{item.when}</span>
              </li>
            ))}
          </ul>
        </SheetBody>
        <SheetFooter>
          <Button>Load older activity</Button>
        </SheetFooter>
      </SheetContent>
    </Sheet>
  )
}
```

### Open state

A sheet holds its open state by default. Use `defaultOpen` to open it at the start.

To control the state, pass `open` and `onOpenChange`. Use this to open the sheet from code, or to close it after an action.

```tsx
import * as React from 'react'
import {
  Button,
  Checkbox,
  Label,
  Sheet,
  SheetBody,
  SheetContent,
  SheetDescription,
  SheetFooter,
  SheetHeader,
  SheetTitle,
  toast,
} from 'ferry-ui'
import { ListFilter } from 'lucide-react'

const STATUSES = ['Paid', 'Open', 'Overdue']

export default function SheetControlled() {
  const [open, setOpen] = React.useState(false)

  function apply() {
    toast.success('Filters applied')
    // The work is done: close the sheet from the code.
    setOpen(false)
  }

  return (
    <>
      <Button icon={<ListFilter />} onClick={() => setOpen(true)}>
        Filters
      </Button>
      <Sheet open={open} onOpenChange={setOpen}>
        <SheetContent>
          <SheetHeader>
            <SheetTitle>Filters</SheetTitle>
            <SheetDescription>Show only the invoices with these statuses.</SheetDescription>
          </SheetHeader>
          <SheetBody>
            {STATUSES.map((status) => (
              <Label key={status}>
                <Checkbox defaultChecked={status !== 'Paid'} />
                {status}
              </Label>
            ))}
          </SheetBody>
          <SheetFooter>
            <Button variant="primary" onClick={apply}>
              Apply filters
            </Button>
          </SheetFooter>
        </SheetContent>
      </Sheet>
    </>
  )
}
```

### No close button

Set `showCloseButton={false}` to remove the close button. Then put a `SheetClose` in the footer.

```tsx
import {
  Button,
  Sheet,
  SheetBody,
  SheetClose,
  SheetContent,
  SheetDescription,
  SheetFooter,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
} from 'ferry-ui'

export default function SheetNoCloseButton() {
  return (
    <Sheet>
      <SheetTrigger asChild>
        <Button>Read the terms</Button>
      </SheetTrigger>
      <SheetContent showCloseButton={false}>
        <SheetHeader>
          <SheetTitle>Terms of service</SheetTitle>
          <SheetDescription>The version of March 2026.</SheetDescription>
        </SheetHeader>
        <SheetBody className="text-[13px] text-foreground-light">
          <p>Each member of the workspace must accept the terms before the first invoice.</p>
          <p>An admin can read the accepted version in the settings of the workspace.</p>
        </SheetBody>
        {/* The panel has no close button: the footer gives the way out. */}
        <SheetFooter>
          <SheetClose asChild>
            <Button variant="primary">Done</Button>
          </SheetClose>
        </SheetFooter>
      </SheetContent>
    </Sheet>
  )
}
```

## Accessibility

- The focus moves into the sheet when it opens. It stays in the sheet until the sheet closes.
- <Kbd>Esc</Kbd> closes the sheet. A click outside the panel also closes it.
- `closeLabel` sets the accessible name of the close button. The default is "Close".
- If a sheet has no `SheetDescription`, pass `aria-describedby={undefined}` to `SheetContent`.

## API reference

Each part also accepts the props of its Radix UI primitive and the attributes of its element.

### Sheet

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` | `ReactNode` |  |  |
| `open` | `boolean` |  |  |
| `defaultOpen` | `boolean` |  |  |
| `onOpenChange` | `((open: boolean) => void)` |  |  |
| `modal` | `boolean` |  |  |

### SheetTrigger

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `asChild` | `boolean` |  |  |

### SheetContent

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `side` | `"top" \| "right" \| "bottom" \| "left"` | `right` | Edge the panel slides in from. `right` (default) for detail and edit panels, `left` for navigation drawers, `bottom` for mobile action sheets, `top` rarely (announcements, search). |
| `showCloseButton` | `boolean` | `true` | Renders the top-right close (X) button. Default `true`; when `false`, give the footer a way out. |
| `closeLabel` | `string` | `Close` | Accessible name of the built-in close (X) button. Default `"Close"`; pass a translation in localized apps. |
| `forceMount` | `true` |  | Used to force mounting when more control is needed. Useful when controlling animation with React animation libraries. |
| `asChild` | `boolean` |  |  |
| `deferPointerDownOutside` | `boolean` |  | When `true`, a `'pointerdown'` event outside of the layered element will wait for the interaction's click event before dispatching, allowing third-party code to stop propagation of later events and cancel dismissal. |
| `onEscapeKeyDown` | `((event: KeyboardEvent) => void)` |  | Event handler called when the escape key is down. Can be prevented. |
| `onPointerDownOutside` | `((event: PointerDownOutsideEvent) => void)` |  | Event handler called when the a `pointerdown` event happens outside of the `DismissableLayer`. Can be prevented. |
| `onFocusOutside` | `((event: FocusOutsideEvent) => void)` |  | Event handler called when the focus moves outside of the `DismissableLayer`. Can be prevented. |
| `onInteractOutside` | `((event: FocusOutsideEvent \| PointerDownOutsideEvent) => void)` |  | Event handler called when an interaction happens outside the `DismissableLayer`. Specifically, when a `pointerdown` event happens outside or focus moves outside of it. Can be prevented. |
| `onOpenAutoFocus` | `((event: Event) => void)` |  | Event handler called when auto-focusing on open. Can be prevented. |
| `onCloseAutoFocus` | `((event: Event) => void)` |  | Event handler called when auto-focusing on close. Can be prevented. |

### SheetHeader

`SheetHeader` has no props of its own. It accepts the attributes of the element it renders.

### SheetTitle

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `asChild` | `boolean` |  |  |

### SheetDescription

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `asChild` | `boolean` |  |  |

### SheetBody

`SheetBody` has no props of its own. It accepts the attributes of the element it renders.

### SheetFooter

`SheetFooter` has no props of its own. It accepts the attributes of the element it renders.

### SheetClose

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `asChild` | `boolean` |  |  |
