# Sheet

Package: @commenda/pathfinder@0.1.0
Page: /docs/components/sheet
Figma: https://www.figma.com/design/dn7BkGYEfIyP0o2zfesfVh/The-System?node-id=379-2

```tsx
import { Button } from '@commenda/pathfinder/button';
import { Input } from '@commenda/pathfinder/input';
import { Sheet } from '@commenda/pathfinder/sheet';

export const SheetDefault = () => (
  <Sheet
    title="Delaware annual report 2026"
    description="Acme Inc. · due 1 March 2027"
    trigger={<Button variant="secondary">Open filing</Button>}
    footerConfig={{}}
  >
    <Input label="Filing reference" defaultValue="DE-2026-001" />
    <p>The page remains available while reviewing this filing.</p>
  </Sheet>
);
```


Import `Sheet` from `@commenda/pathfinder/sheet`.

## API reference [#api-reference]

| Prop | Type | Requirement | Behavior |
| --- | --- | --- | --- |
| `side` | `"right" \| "bottom"` | conditional | right |
| `size` | `"sm" \| "md" \| "lg"` | optional | md; right widths 400 / 520 / 720px |
| `modal` | `boolean` | optional | false; page stays usable and page clicks do not dismiss |
| `dragConfig` | `{ dismissible: boolean; }` | optional | Bottom only; true by default |
| `title` | `string` | required | Required accessible title |
| `description` | `ReactNode` | optional | Required accessible title |
| `trigger` | `ReactElement<any, string \| React.JSXElementConstructor<any>>` | optional | Required accessible title |
| `children` | `ReactNode` | required | Required accessible title |
| `open` | `boolean` | conditional | Controlled or uncontrolled |
| `defaultOpen` | `boolean` | optional | Controlled or uncontrolled |
| `onOpenChange` | `((open: boolean) => void)` | conditional | Controlled or uncontrolled |
| `footerConfig` | `OverlayFooterConfig` | optional | Shared action/focus/guard contracts |
| `dismissConfig` | `OverlayDismissConfig` | optional | Shared action/focus/guard contracts |
| `focusConfig` | `OverlayFocusConfig` | optional | Shared action/focus/guard contracts |
| `className` | `string` | optional | Shared action/focus/guard contracts |

### dragConfig fields

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `dismissible` | `boolean` | Yes |  |

### footerConfig fields

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `primaryAction` | `OverlayAction` | No |  |
| `secondaryAction` | `OverlayAction` | No |  |
| `leadingContent` | `ReactNode` | No |  |
| `cancelLabel` | `string` | No |  |

### dismissConfig fields

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `outsidePress` | `boolean` | No |  |
| `beforeClose` | `((reason: OverlayCloseReason) => boolean \| Promise<boolean>)` | No |  |
| `onError` | `((error: unknown) => void)` | No |  |

### focusConfig fields

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `initialFocusRef` | `RefObject<HTMLElement \| null>` | No |  |
| `returnFocusRef` | `RefObject<HTMLElement \| null>` | No |  |

`open` with `onOpenChange` is controlled; otherwise use `defaultOpen`. Primary actions never imply success or automatically close the surface. Close it after the application completes its work.

`footerConfig` accepts `primaryAction`, `secondaryAction`, `leadingContent`, and `cancelLabel`. Actions have `label`, `onClick`, native `type`/`form`, `disabled`, `disabledReason`, `loadingConfig`, `destructive`, and icons. Without a secondary action the footer renders Cancel. A footer is absent unless configured. Submit uses native form IDs; IDs must be unique.

## Bottom sheet [#bottom-sheet]


```tsx
import { Button } from '@commenda/pathfinder/button';
import { Input } from '@commenda/pathfinder/input';
import { Sheet } from '@commenda/pathfinder/sheet';

export const SheetBottom = () => (
  <Sheet
    side="bottom"
    title="Filter filings"
    description="Drag the handle down or use Close to dismiss."
    trigger={<Button variant="secondary">Filter filings</Button>}
    footerConfig={{}}
  >
    <Input label="Reference" placeholder="DE-2026" />
    <div style={{ minHeight: 280, paddingTop: 16 }}>
      <p>Keep short filters in the centered content column.</p>
    </div>
  </Sheet>
);
```


The centered column is at most 640px; the sheet is at most 96dvh. Drag only the handle: a downward release over 0.4px/ms or distance over 25% of sheet height requests dismissal. Close, Escape, Cancel, and keyboard activation of the handle are equivalent alternatives. The body remains independently scrollable.

## Modal mode [#modal-mode]


```tsx
import { Button } from '@commenda/pathfinder/button';
import { Input } from '@commenda/pathfinder/input';
import { Sheet } from '@commenda/pathfinder/sheet';

export const SheetModal = () => (
  <Sheet
    modal
    title="Edit registered address"
    trigger={<Button variant="secondary">Open modal sheet</Button>}
    footerConfig={{}}
  >
    <Input label="Address line 1" defaultValue="100 Main Street" />
  </Sheet>
);
```


Modal mode couples the backdrop, focus containment, and page scroll lock. Outside dismissal remains off unless `dismissConfig.outsidePress=true`. Nonmodal sheets always ignore page clicks. Right sheets have a 16px inset and clamp to the viewport.

## Unsaved changes [#unsaved-changes]


```tsx
import { useRef, useState } from 'react';

import { Button } from '@commenda/pathfinder/button';
import { ConfirmDialog } from '@commenda/pathfinder/dialog';
import { Input } from '@commenda/pathfinder/input';
import { Sheet } from '@commenda/pathfinder/sheet';

export const SheetGuarded = () => {
  const [open, setOpen] = useState(false);
  const [confirmation, setConfirmation] = useState(false);
  const decision = useRef<((allow: boolean) => void) | null>(null);

  const finish = (allow: boolean) => {
    decision.current?.(allow);
    decision.current = null;
    setConfirmation(false);
  };

  return (
    <Sheet
      open={open}
      onOpenChange={setOpen}
      title="Edit entity"
      trigger={
        <Button variant="secondary">Edit with discard protection</Button>
      }
      dismissConfig={{
        beforeClose: () =>
          new Promise<boolean>((resolve) => {
            decision.current = resolve;
            setConfirmation(true);
          }),
      }}
      footerConfig={{}}
    >
      <Input label="Legal name" defaultValue="Acme Inc." />
      <ConfirmDialog
        open={confirmation}
        onOpenChange={(next) => {
          if (!next) finish(false);
        }}
        title="Discard changes?"
        description="Your unsaved changes will be lost."
        cancelLabel="Keep editing"
        confirmAction={{
          label: 'Discard changes',
          destructive: true,
          onClick: () => {
            finish(true);
          },
        }}
      />
    </Sheet>
  );
};
```


`beforeClose` also receives `swipe`. The application can show a ConfirmDialog and resolve the pending decision. Keep a single Sheet open, with at most its confirmation above it. The application owns request cancellation and should resolve outstanding confirmations if it removes the sheet. Read the [overlay rules](/docs/components/sheet#design-rules).

## Design rules [#design-rules]

Figma guidance and reviewed implementation decisions for this component. Rule IDs remain stable for source comments and AI references.

### Sheet [#sheet]

- **SHEET-001:** Right widths 400/520/720px, default 520, inset 16, viewport height minus 32. Bottom maximum 96dvh, pearl-100 surface, top radius12, centered640 column, handle48×6; safe-area bottom padding.
- **SHEET-002:** Nonmodal by default, unlike Kakashi’s legacy default. Page remains usable and page clicks do not close it. Modal mode couples backdrop, focus containment, and scroll lock. Keep one Sheet, with its confirmation when necessary.
- **SHEET-003:** Bottom handle only: distance over25% or downward release velocity over0.4px/ms, pointer capture, multi-pointer protection, cancel/reset. Body scroll does not drag. Guard swipe just like Close; keyboard, Escape, and Cancel alternatives remain available. Base UI Drawer1.9 uses a fixed0.5px/ms threshold and broader gesture handling, so the private gesture layer wraps Base UI Dialog rather than copying app logic.

### Motion and edge cases [#motion-and-edge-cases]

Occasional overlays use motion for spatial consistency. Motion paths and timings follow Kakashi: Dialog fades and moves from 95% scale with a 2% vertical offset in 200ms both ways; Popover fades from 95% scale and an 8px offset toward its trigger in 150ms both ways. Popover direction follows the resolved placement after collision handling. Right Sheet slides in for 500ms and out for 300ms; bottom Sheet slides for 500ms both ways. Sheet panels do not fade during normal motion, and modal backdrops follow the panel duration.

Pathfinder retains its foundation ease-out curve for Dialog/Popover and drawer curve `(0.32,0.72,0,1)` for Sheets. These are deliberate curve choices; this is not a copy of Kakashi’s legacy Radix keyframe implementation. Base UI continues to own presence, focus and dismissal. Transitions use a single `transform` value plus opacity where relevant, so Tailwind v4’s separate scale/translate properties cannot bypass interpolation. Closing or reopening mid-transition reverses from the current rendered position.

Keyboard opening and dismissal remain immediate, including Close and Cancel activation. Reduced motion removes scale and translation: Dialog/Popover keep their fade, while Sheets change position immediately and their modal backdrop can fade. Drag writes only the popup transform, never layout or inherited animation variables. Programmatic openings animate by default; explicit keyboard interactions suppress motion.
