A bottom sheet for phones that rises from the edge, swipes away, and still closes from the keyboard.
Basic
A float panel that rises from the bottom edge over the scrim. It is as tall as its content, up to 92% of the screen, so a little of the page always shows above it. It is full width on a phone and 36rem wide, centered, on anything wider.
From the top, a sheet holds:
- Grabber: the decorative handle that shows it can be swiped.
- Header: the title, an optional description and a close button. The title is required because it names the sheet. It is an
h2in the card title step. - Body: the content.
- Footer: pinned under the body, clear of a phone's home indicator.
Example
Example
import { Button, Checkbox, CheckboxGroup, Sheet, SheetTrigger, TextField } from '@snapshot-ui/react';
import type { ReactElement } from 'react';
export default function SheetBasic(): ReactElement {
return (
<SheetTrigger>
<Button variant="secondary">Filter transactions</Button>
<Sheet
title="Filter transactions"
description="Narrow the list to what you are looking for."
footer={
<Button slot="close" style={{ width: '100%' }}>
Show 24 transactions
</Button>
}
>
<div style={{ display: 'grid', gap: 24 }}>
<TextField label="Payee" />
<CheckboxGroup label="Status" defaultValue={['cleared']}>
<Checkbox value="cleared">Cleared</Checkbox>
<Checkbox value="pending">Pending</Checkbox>
</CheckboxGroup>
</div>
</Sheet>
</SheetTrigger>
);
}A list that names itself
When the content names itself, such as a list of pages, isTitleHidden keeps the title for assistive technology only and the header shows just the close button. This is the phone shell's "More" sheet: the pages its tab bar has no room for, with the signed-in account in the footer.
Example
Example
import { Button, Link, Sheet, SheetTrigger, Text } from '@snapshot-ui/react';
import type { ReactElement } from 'react';
const PAGES = ['Reports', 'Budgets', 'Members', 'Settings', 'Help'];
export default function SheetMore(): ReactElement {
return (
<SheetTrigger>
<Button variant="secondary">More</Button>
<Sheet
title="More"
isTitleHidden
footer={
<div style={{ minWidth: 0 }}>
<Text as="span" style={{ display: 'block' }}>
Ada Lovelace
</Text>
<Text as="span" size="small" tone="muted" style={{ display: 'block' }}>
ada@northwind.example
</Text>
</div>
}
>
<nav aria-label="More pages">
<ul style={{ display: 'grid', gap: 12, margin: 0, padding: 0, listStyle: 'none' }}>
{PAGES.map((page) => (
<li key={page}>
<Link href={`#${page.toLowerCase()}`}>{page}</Link>
</li>
))}
</ul>
</nav>
</Sheet>
</SheetTrigger>
);
}Stop points
snapPoints sets the heights the sheet rests at, in pixels or as percentages of the sheet. It opens at the first. With snapPoints={['40%', '100%']}, it opens at 40%, a swipe up takes it to full height, and a swipe down takes it back to 40% or closes it. The body scrolls only once the sheet is fully open. At a partial stop, a swipe on the content moves the sheet instead, as on iOS. Without stop points, the sheet opens fully and a tall body scrolls at once.
Example
Example
import { Button, Sheet, SheetTrigger, Text } from '@snapshot-ui/react';
import type { ReactElement } from 'react';
const PAYEES = ['Northwind Supplies', 'City Water', 'Corner Bakery', 'Metro Transit', 'Paper and Ink'];
/** Thirty payments, more than a phone's screen holds. */
const PAYMENTS = Array.from({ length: 30 }, (_, index) => ({
payee: PAYEES[index % PAYEES.length] ?? 'Northwind Supplies',
day: `October ${String(30 - index)}`,
}));
export default function SheetSnapPoints(): ReactElement {
return (
<SheetTrigger>
<Button variant="secondary">Show every transaction</Button>
<Sheet
title="Transactions"
description="Everything this month, newest first."
snapPoints={['40%', '100%']}
>
<ul style={{ display: 'grid', gap: 12, margin: 0, padding: 0, listStyle: 'none' }}>
{PAYMENTS.map(({ payee, day }) => (
<li key={day}>
<Text as="span" style={{ display: 'block' }}>
{payee}
</Text>
<Text as="span" size="small" tone="muted" style={{ display: 'block' }}>
{day}
</Text>
</li>
))}
</ul>
</Sheet>
</SheetTrigger>
);
}Usage
Open a sheet in one of three ways:
SheetTrigger: wraps the button that opens it and the sheet. The trigger owns the state, so putonOpenChangeon theSheetTrigger. On theSheetit is not called.isOpenandonOpenChange: control it yourself.defaultOpen: start it open.
Sheet is a client component, and a server component can render it, as the examples on this page do. A button with slot="close" closes the sheet it is in, and children may also be a function that receives close.
The swipe is an enhancement. React Aria drives it with the browser's own scroll, so it has a phone's momentum. On a computer, a mouse wheel or trackpad scroll over the sheet moves it the same way. Scrolling up past the top of a list in the sheet also moves the sheet down and can close it.
While the sheet is dismissable, the close button and Escape always work, since a keyboard cannot swipe. With isDismissable={false}, a swipe, Escape and a scrim press do nothing, and only the close button or your own slot="close" button closes it.
A sheet is a phone surface. It still works on a tablet at 36rem, but on wider screens the same content is usually a dialog or a drawer. A second sheet opened from inside a first, such as a filter sheet from the "More" sheet, stacks over it with one scrim, and Escape closes the top one. A sheet opened from a dark card is dark while the page stays light, as Theming explains.
Not yet proven in any engine:
- React Aria's iOS paths, its allowance for Safari's toolbar and its iOS scroll lock, since no desktop engine takes them.
- A real on-screen keyboard. The sheet is capped at 92% of the visual viewport but stays on the layout viewport's bottom edge. Only the cap has been measured.
- Stop points under a finger. They are tested with a wheel in Chromium only.
- A field with
autoFocusinside the sheet, which takes focus as it mounts, before React Aria moves focus in.
The touch swipe has run in Chromium. WebKit and Firefox were driven with the wheel. Known issues:
- On iOS, React Aria's scroll lock gives every element
overscroll-behavior: contain, so pulling down on a long list at its top does not move the sheet. - On desktop Safari, after a fast swipe, React Aria's exit can wait for a scroll end that has already passed. The sheet then stays on the page, unseen, still blocking the page and holding focus. This was seen in the Linux build of WebKit, not the macOS one.
- A long body at a partial stop is already a tab stop, though it scrolls only once the sheet is fully open.
Props
ref goes on the dialog element, which takes focus. className goes on the sheet and overlayClassName on its scrim, each merged after the library's classes. A class on the sheet may restyle .sheet-body and .sheet-footer. The header and other inner parts may change between releases.
children ((opts: { close: () => void; }) => ReactNode) | ReactNode | The body. It scrolls once the sheet is fully open. |
|---|---|
className string | ((values: SheetRenderProps & { defaultClassName: string | undefined; }) => string) | Goes on the sheet panel, merged after the library's classes. |
closeLabel string | The close button's accessible name. Default: 'Close' |
defaultOpen boolean | |
description ReactNode | A line under the title, in slate. It becomes the sheet's accessible description. |
footer ReactNode | Pinned under the body, padded for the safe area. |
hasCloseButton boolean | An X in the header, the keyboard's way out. Default: true |
hasGrabber boolean | The decorative handle above the title. Default: true |
isDismissable boolean | A swipe, Escape or a scrim press closes it. Default: true |
isOpen boolean | Open state, for a sheet without a |
isTitleHidden boolean | Keeps the title for assistive technology only, for a sheet whose content names itself, such as a "More" list. Default: false |
onOpenChange ((isOpen: boolean) => void) | Called when the sheet asks to open or close. In a |
overlayClassName string | Goes on the backdrop, the sheet's scrim. |
snapPoints (string | number)[] | Heights the sheet rests at, in px or as percentages of the sheet. It opens at the first. Passed through to React Aria. |
title ReactNode | The title, an h2 in the header. It names the sheet. Required. |
Accessibility
The sheet is a dialog named by its title, even when hidden, and described by its description.
- Focus moves into the sheet once it has risen, Tab stays inside, and closing returns focus to the button that opened it.
- The grabber is hidden from assistive technology.
- While open, the page behind is hidden from assistive technology and does not scroll.
- Under reduced motion, the sheet appears and leaves without sliding, and the scrim does not fade.
| Tab | Cycles through the controls inside. |
|---|---|
| Escape | Closes the sheet. |
| EnterSpace | Press a button or follow a link. |
Design rules
What keeps sheets in the system.
Use a sheet on a phone, for a list or a short form.
Keep the close button, so the way out does not depend on a swipe.
Give a long list stop points, so it opens part way and the page stays in view.
Never use a sheet on a desktop, where a dialog or a drawer fits.
Never hide the close button on a sheet that can be dismissed.
Never make a sheet taller than 92% of the screen.