Sheet

A panel that slides in over the page from an edge of the window.

BetaSince 1.0.0
On this page

Import

import { Sheet } from '@envsemble/envui';

Usage

A Sheet opens from its trigger, such as a Button, and holds its title, a description, its content as children, which scrolls, and actions at the end, the primary action last.

  • DialogClose, a Button, closes it.
  • Leave it uncontrolled, or control it with open and onOpenChange; defaultOpen opens it on the first render.
  • Name it with a title, or an aria-label where it has none.
FilterPanel.tsx
import { useState } from 'react';
import {
  Button,
  DialogClose,
  List,
  Radio,
  RadioGroup,
  Select,
  Column,
  Text,
  Sheet,
} from '@envsemble/envui';
import { IconFilterList } from '@envsemble/envui/icons/FilterList';

const projects = [
  { name: 'Northwind launch', status: 'active', owner: 'Priya Shah' },
  { name: 'Hiring guide', status: 'active', owner: 'Alex Morgan' },
  { name: 'Office move', status: 'paused', owner: 'Alex Morgan' },
  { name: 'Budget 2027', status: 'active', owner: 'Sam Lee' },
  { name: 'Support rota', status: 'done', owner: 'Priya Shah' },
];

const owners = [
  { value: 'anyone', label: 'Anyone' },
  { value: 'Alex Morgan', label: 'Alex Morgan' },
  { value: 'Priya Shah', label: 'Priya Shah' },
  { value: 'Sam Lee', label: 'Sam Lee' },
];

export default function FilterPanel() {
  const [status, setStatus] = useState('any');
  const [owner, setOwner] = useState('anyone');
  const shown = projects.filter(
    (project) =>
      (status === 'any' || project.status === status) &&
      (owner === 'anyone' || project.owner === owner),
  );
  return (
    <Column gap={4} align="start">
      <Sheet
        // Shown open in the docs; without defaultOpen, it opens from its trigger.
        defaultOpen
        trigger={
          <Button variant="secondary" startIcon={<IconFilterList />}>
            Filters
          </Button>
        }
        title="Filters"
        closeLabel="Close filters"
        actions={
          <>
            <Button
              variant="text"
              onClick={() => {
                setStatus('any');
                setOwner('anyone');
              }}
            >
              Clear filters
            </Button>
            <DialogClose variant="primary">
              Show {shown.length} {shown.length === 1 ? 'project' : 'projects'}
            </DialogClose>
          </>
        }
      >
        <Column gap={5}>
          <RadioGroup
            orientation="vertical"
            label="Status"
            value={status}
            onValueChange={setStatus}
          >
            <Radio value="any" label="Any status" />
            <Radio value="active" label="Active" />
            <Radio value="paused" label="Paused" />
            <Radio value="done" label="Done" />
          </RadioGroup>
          <Select
            label="Owner"
            items={owners}
            value={owner}
            onValueChange={(value) => setOwner(value ?? 'anyone')}
          />
        </Column>
      </Sheet>
      {/* The list takes the width of a column of the page. */}
      {shown.length > 0 ? (
        <List.Root aria-label="Projects" style={{ inlineSize: 'min(100%, 24rem)' }}>
          {shown.map((project) => (
            <List.Item key={project.name} label={project.name} description={project.owner} />
          ))}
        </List.Root>
      ) : (
        <Text emphasis="muted">No projects match these filters.</Text>
      )}
    </Column>
  );
}

Side and width

side is end, the default, start or bottom, in the text direction: end is the right edge in left-to-right text. width is md, 24 rem and the default, or lg, 44 rem, for a side sheet; the widths follow the window classes of its container. A bottom sheet is as tall as its content, and as wide as the window up to 40 rem.

<Sheet side="bottom" title="Share" trigger={<Button>Share</Button>}>
  …
</Sheet>

Beside the page

With modal={false} the page stays usable beside the sheet. disablePointerDismissal keeps it open while people choose another row, and finalFocus returns focus to the row it showed last; initialFocus picks where focus goes on opening.

<Sheet
  open={person !== undefined}
  onOpenChange={(open) => !open && setSelected(null)}
  modal={false}
  disablePointerDismissal
  finalFocus={opener}
  title={person?.name ?? 'Person'}
  closeLabel="Close details"
>
  …
</Sheet>

Resizable

With resizable, people resize a side sheet by its inner edge, between minWidth and maxWidth, 16 and 60 rem by default, and never past the window.

  • The arrow keys on the handle resize it 1 rem, 5 rem with Shift.
  • It starts at defaultWidth; width and onWidthChange let the application keep the width, as a number of rem; onWidthChange receives a ResizeDetails, { committed }.
  • A CSS length as width, such as min(30rem, 90%), is ignored then, with a warning while developing.
  • resizeLabel names the handle.
<Sheet
  resizable
  minWidth={20}
  maxWidth={48}
  title="Northwind launch"
  trigger={<Button>Project details</Button>}
>
  …
</Sheet>

Pending work

While pending, Esc, presses outside, swipes and the close button can't dismiss the sheet, so work started from it finishes first. The close button and every DialogClose inside are disabled, and the last action, where it is a Button that sets no pending of its own, shows it pending.

Composition

The close button is an IconButton named by closeLabel, which closeButton={false} leaves out. Put the content in a Column, and show the next level inside the sheet rather than opening another.

Customize

A Sheet shares Dialog's parts: its heading, env-dialog-heading, with the title, env-dialog-title, and the close button; the description, env-dialog-description; and the actions, env-dialog-actions. The panel is env-sheet, its content, which scrolls between the heading and the actions, env-sheet-body, the backdrop behind a modal sheet env-sheet-backdrop and the handle on a resizable sheet's inner edge env-sheet-resize-handle, the same grip as Panes.Divider's.

Accessibility

You provide

  • A title, or an aria-label, and a closeLabel where several panels close nearby.
  • finalFocus when the trigger is gone, or something other than trigger opened the sheet.

envUI provides

  • role="dialog", named by the title and described by the description, and the trigger's aria-haspopup, aria-expanded and aria-controls.
  • Focus moved in on open, kept inside a modal sheet and returned on close.
  • In a modal sheet, the rest of the page hidden, closed to presses and locked from scrolling.
  • A close button for touch screen reader users, who have no Esc, disabled while pending.
  • Its side, slide and touch swipe in the text direction.
  • With resizable, a focusable separator on its inner edge, named Resize, with its width as its value.

Props

Sheet

A panel that slides in over the page from an edge of the window, for filters, details, an inspector or choices: a side sheet from the end or the start edge, or a bottom sheet. It is a modal by default, which traps focus and returns it on close, or beside the page with modal false. A side sheet takes the whole width of a compact window, and a finger's swipe toward its edge closes it; a mouse or a pen never swipes it. With `resizable`, people resize it by its inner edge, with the same handle as between Panes. className, style, ref and data attributes go to the sheet's surface.

PropTypeDescription
open
boolean
Whether the sheet is open, for a sheet the application controls with onOpenChange.
defaultOpenDefault false
boolean
Whether a sheet the application doesn't control is open on the first render.
onOpenChange
(open: boolean, details: DialogOpenChangeDetails) => void
Called with the requested state, and why, when the sheet asks to open or close: from the trigger, Escape, an outside press, the close button, a swipe of a finger toward its edge or a DialogClose. Not called for a dismissal that pending blocks.
trigger
ReactElement
The element that opens the sheet, such as a Button. It reports the sheet it opens, and focus returns to it on close. Omit it for a sheet the application opens with open.
sideDefault 'end'
'bottom''start''end'
The edge of the window the sheet slides in from: end, the right in left-to-right text, or start, the left, as a side sheet as tall as the window; or bottom, as a bottom sheet as wide as the window up to 40 rem and as tall as its content. start and end follow the text direction, so they are mirrored in right-to-left text.
title
ReactNode
The sheet's heading and accessible name, such as Filters. Without one, name the sheet with aria-label.
aria-label
string
The sheet's accessible name where it has no visible title.
description
ReactNode
Muted text under the title that describes the sheet to assistive technology too.
children
ReactNode
The content under the heading, such as filters, details or choices. It scrolls.
actions
ReactNode
Actions at the end of the content, such as Apply, with the primary action last. While the sheet is pending, the last one shows it, where it is a Button that sets no pending of its own.
closeButtonDefault true
boolean
Whether the sheet shows its close button, an IconButton with a cross at the end of the heading. Leave it out only where the sheet holds another control that closes it.
closeLabelDefault 'Close', or EnvUIProvider's messages.dialog.close
string
The close button's name.
widthDefault 'md'
'md''lg'number(string & {})
A side sheet's width: md, 24 rem, for details and filters; lg, 44 rem, for content-heavy panels such as tables and comparisons; or any CSS length, such as min(20rem, 100% - 2rem). md takes the whole width of a compact window, lg of a compact or medium one, where the page beside it would be a sliver. A number is a width in rem: with `resizable`, the width the application controls with onWidthChange, while a CSS length is ignored, and warned of while developing: the sheet starts at `defaultWidth`. A bottom sheet ignores it.
resizableDefault false
boolean
Lets people resize a side sheet by its inner edge, the one toward the page: a handle there that the pointer drags and the arrow keys move, between `minWidth` and `maxWidth` and never wider than the window, which a double click returns to `defaultWidth`. The sheet is then as wide as it at every window width. A bottom sheet ignores it.
defaultWidthDefault 24, or 44 where width is lg
number
The width in rem a `resizable` side sheet starts at until people resize it, and that a double click on the handle returns it to.
onWidthChange
(width: number, details: ResizeDetails) => void
Receives each new width of a `resizable` side sheet, in rem: while dragging with committed false, and with committed true as a drag ends, a key moves the handle or a double click resets it. Store the committed width to keep it, and pass it back as `width`.
minWidthDefault 16
number
The smallest width of a `resizable` side sheet, in rem.
maxWidthDefault 60
number
The largest width of a `resizable` side sheet, in rem; it never takes more than the window.
resizeLabelDefault 'Resize', or EnvUIProvider's messages.dialog.resize
string
The resize handle's name.
modalDefault true
boolean
true traps focus in the sheet over a backdrop, hides the page from assistive technology, blocks presses on it and locks its scroll. false leaves the page usable beside the sheet, for an inspector; it then closes when focus or a press goes outside it, unless disablePointerDismissal.
disablePointerDismissalDefault false
boolean
Keep the sheet open on presses outside it, and a non-modal sheet also when focus moves outside, such as an inspector beside the page it inspects. Escape and the close buttons still close it.
initialFocus
DialogFocusTarget
The element to focus when the sheet opens, as a ref or a function of how it was opened. By default focus moves to the first element inside that takes Tab, such as the close button, or to the sheet itself when opened by touch or when nothing inside takes focus.
finalFocus
DialogFocusTarget
The element to focus when the sheet closes, as a ref or a function of how it closed, such as the main content after a navigation. By default focus returns to the trigger, or to the element that had focus when it opened.
pendingDefault false
boolean
Work started from the sheet is in progress: Escape, outside presses, swipes and the close button cannot dismiss it until pending returns to false. The close button and every DialogClose inside are disabled, the primary action, the last of actions, shows it pending, and the sheet reports busy.
skeletonDefault false
boolean
Shows it and its content as a busy skeleton while that loads; turning it on or off renders the content anew.

Also accepts the native props of <div>, such as ref, className and aria-* attributes.

Part names

Each part's class and state attributes are public and versioned, for an application's CSS to style.

env-sheet
The sheet.
env-sheet-backdrop
The backdrop behind a modal sheet.
env-sheet-body
The sheet's content, which scrolls between the heading and the actions.
env-sheet-resize-handle
The handle on a resizable side sheet's inner edge, a separator that resizes it.
env-sheet-viewport
The viewport the sheet slides in from, at its edge.
data-ending-style
Any value, or present
data-env-skeleton
Present
data-resizable
Present
data-side
bottom · end · start on env-sheet, env-sheet-viewport
data-starting-style
Any value, or present
data-swiping
Any value, or present
data-width
Any value, or present

Strings

EnvUIProvider's messages overrides each, and a component's own label prop wins over it.

messages.dialog.close
"Close" The built-in close button's label.
messages.dialog.resize
"Resize" Names the handle that resizes a resizable side Sheet.
messages.dialog.width
({ width }) => string The resize handle's value: the Sheet's width in rem, such as 24 rem, in the locale's digits.

Changelog

1.0.0

Sheet, which was Drawer in 1.0.0-prerelease.0, adds side="bottom", and its widths follow the window classes of its container. Drawer, for the panels that were Dialog's placement="right", with side, width, a swipe to close and the text direction.

1.0.0-prerelease.6

Under reduced motion, too, the sheet fades in with --env-motion-ease-enter, as it does while it slides.

As it slides in, the sheet is solid within --env-motion-duration-fast, so the page doesn't show through a sheet as wide as a phone's window; the backdrop keeps the slower fade.

While a sheet is pending, every DialogClose inside is disabled as its close button is, and its last action, where it is a Button that sets no pending of its own, shows it pending.

1.0.0-prerelease.5

A Sheet has no edge line: its fill and its scrim set it apart, and its edge returns at high contrast. A side sheet keeps its actions at its foot, however little its body holds.

A Sheet is on the navigation's background, --env-color-bg-default, rather than the popup's, in every theme, so navigation that opens in it in a narrow window keeps its color.

1.0.0-prerelease.2

With resizable, people resize a side sheet by its inner edge, between minWidth and maxWidth, from defaultWidth; width takes a number of rem, which onWidthChange keeps. The handle is env-sheet-resize-handle, the same grip as Panes.Divider's. Only a finger swipes a sheet closed; a mouse or a pen no longer moves it.