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 h2 in the card title step.
  • Body: the content.
  • Footer: pinned under the body, clear of a phone's home indicator.

Example

Follows the page theme and corners
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

Follows the page theme and corners
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

Follows the page theme and corners
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 put onOpenChange on the SheetTrigger. On the Sheet it is not called.
  • isOpen and onOpenChange: 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 autoFocus inside 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. false keeps it open until a button closes it (React Aria's preventDismissal).

Default: true

isOpen
boolean

Open state, for a sheet without a SheetTrigger. Controlled and uncontrolled.

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 SheetTrigger the trigger owns the state, so put it there.

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.
TabCycles through the controls inside.
EscapeCloses the sheet.
EnterSpacePress 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.

esc

↑ ↓ to move↵ to open