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
openandonOpenChange;defaultOpenopens it on the first render. - Name it with a
title, or anaria-labelwhere it has none.
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;widthandonWidthChangelet the application keep the width, as a number of rem;onWidthChangereceives aResizeDetails,{ committed }. - A CSS length as
width, such asmin(30rem, 90%), is ignored then, with a warning while developing. resizeLabelnames 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 anaria-label, and acloseLabelwhere several panels close nearby. finalFocuswhen the trigger is gone, or something other thantriggeropened the sheet.
envUI provides
role="dialog", named by the title and described by the description, and the trigger'saria-haspopup,aria-expandedandaria-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.
| Prop | Type | Description |
|---|---|---|
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 | ( | 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( | 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 | ( | 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-sidebottom·end·starton env-sheet, env-sheet-viewportdata-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 }) => stringThe 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'splacement="right", withside,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 nopendingof 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, betweenminWidthandmaxWidth, fromdefaultWidth;widthtakes a number of rem, whichonWidthChangekeeps. The handle isenv-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.