---
title: Sheet
description: The dialog docked to an edge, for content tall enough that centring it would mean scrolling a floating box.
source: https://adamjurek.com/components/sheet
---

## Home


Sheet is a dialog docked to an edge instead of centred - for a list or a
filter form tall enough that centring it would mean scrolling a floating box
in the middle of the page. It is still fully modal: the page behind it stays
unreachable until it closes.

<!-- ::start:showcase demo="sheet" height="380" -->
<!-- ::end:showcase -->

## Why it is built this way

It is the same native `<dialog>` element and the same `open`, `onClose` and
`title` props as a centred dialog - only the geometry differs. `side` writes
`data-side` on the element, so choosing docked over centred is a prop, not a
second component with its own API to learn.


## Install

<!-- ::start:tabs -->

### TanStack

```shell
tanstack add https://adamjurek.com/r/tanstack/sheet.json
```

### shadcn

```shell
pnpm dlx shadcn@latest add https://adamjurek.com/r/shadcn/sheet.json
```

### pnpm

```shell
pnpm add @sushindustries/ui @sushindustries/atoms
```

<!-- ::end:tabs -->

## What you get

| | |
| --- | --- |
| Version | 0.1.0 |
| Category | layout · Overlays |
| Files | `sheet.tsx` |
| Dependencies | None |
| Also installs | `icon` |
| Tags | modal, drawer, no-deps |

> [!NOTE] No runtime dependencies
> It brings nothing with it beyond the stylesheet.

## Get Started


Install commands are on Home, attached from the registry - they are not written
here, because a second copy is a copy that goes stale. This tab starts after the
install worked.

## Use it

```tsx
import { Sheet } from "@sushindustries/ui";
import { useState } from "react";

export function FilterButton() {
	const [open, setOpen] = useState(false);

	return (
		<>
			<button type="button" onClick={() => setOpen(true)}>
				Filters
			</button>
			<Sheet open={open} onClose={() => setOpen(false)} title="Filters">
				<p>Filter controls go here.</p>
			</Sheet>
		</>
	);
}
```

## What you should see

A panel sliding in from the right edge (the default `side`), covering the
full height of the viewport, with the page behind it dimmed and unreachable -
`showModal` puts it on the top layer and traps focus inside. Escape, clicking
the dimmed backdrop, and the close button all call `onClose`.

## If nothing happens

Clicking the trigger but seeing nothing usually means `open` isn't actually
becoming `true` - check the state update, not the component. If the sheet
opens but closing does nothing, `onClose` has to update the same `open` state
that opened it; the dialog closes itself natively on Escape and backdrop
click, but if `open` stays `true` afterward, the effect reopens it on the
next render.


## Guides


## Variants

`side` writes `data-side` on the `<dialog>`, which the stylesheet reads to
decide which edge it docks to and which direction it slides from:

```tsx
<Sheet side="left" open={open} onClose={onClose} title="Filters">
```

```css
.sheet[data-side="left"] {
	inset-inline: 0 auto;
}
```

## When not to use it

For content short enough to centre on screen - that's `Dialog`, the same
native-element recipe without the docked geometry. `Sheet` earns its edge
position when the content is a list or a form tall enough that centring it
would mean scrolling a floating box in the middle of the page.


## API


<!-- generated:api -->

## Props

| Prop | Type | Default | Does |
| --- | --- | --- | --- |
| `open` | `boolean` | - | Calls `showModal`. A sheet is still modal - the page behind it cannot be reached. |
| `onClose` | `() => void` | - | Escape, the backdrop and the close button all arrive here. Clear `open` or the two disagree. |
| `title` | `string` | - |  |
| `children` | `ReactNode` | - |  |
| `side?` | `"right" \| "left"` | `"right"` | Which edge it slides from. |

<!-- /generated:api -->

## Notes

`onClose` has to actually flip `open` back to `false`. The `<dialog>` closes
itself natively on Escape and on the backdrop click this component wires up,
but the effect watching `open` calls `showModal()` again on the next render
if the prop hasn't caught up - so `open` and the dialog's own state
disagreeing is what a sheet that "won't close" usually is.

`side` only changes which edge it's docked to and which direction it
animates from; the header, the close button and the scrollable body are the
same regardless.


## Examples


Examples are the tab where the component is shown doing a job, not
demonstrating a prop. The API tab already lists the props.

<!-- ::start:showcase demo="sheet" height="420" -->
<!-- ::end:showcase -->

Press Compare. The frames are real viewports, so a layout that breaks at 320
breaks here too rather than in somebody's hands.

## In a page

```tsx
import { Sheet } from "@sushindustries/ui";
import { useState } from "react";

export function ProductCard() {
	const [open, setOpen] = useState(false);

	return (
		<>
			<div className="card p-4">
				<h3 className="h4 m-0">Reveal</h3>
				<button type="button" onClick={() => setOpen(true)}>
					Quick view
				</button>
			</div>
			<Sheet
				open={open}
				onClose={() => setOpen(false)}
				title="Reveal"
				side="right"
			>
				<p>Fades and rises its children the first time they reach the viewport.</p>
			</Sheet>
		</>
	);
}
```
