Sheet
A dialog that slides in from an edge of the screen, for content that complements the page.
Basic
Slides in from the right. Escape, a click outside, the X or Close dismiss it.
Code
<Sheet title="Edit profile" description="Make changes to your profile here. Click save when you're done.">
<Button slot="trigger">Open</Button>
<SheetBody>
<div class="field">
<label for="name">Name</label>
<input id="name" value={form.name}>
</div>
</SheetBody>
<SheetFooter>
<Button variant="primary" onclick={() => save(form)}>Save changes</Button>
<SheetClose><Button>Close</Button></SheetClose>
</SheetFooter>
</Sheet>Usage
import { Sheet, SheetBody, SheetFooter, SheetClose } from "@elements/ui/sheet";
<Sheet side="right" title="Edit profile" description="Make changes, then save.">
<Button slot="trigger">Edit profile</Button>
<SheetBody>
<input value={form.name}>
</SheetBody>
<SheetFooter>
<Button variant="primary" onclick={() => save(form)}>Save changes</Button>
<SheetClose><Button>Close</Button></SheetClose>
</SheetFooter>
</Sheet>
<!-- controlled: open is two-way -->
<Sheet open={state.filters} side="left" title="Filters">...</Sheet>Examples
Side
side picks the edge: top, right, bottom or left.
Code
<Sheet side="top" title="Edit profile">...</Sheet>
<Sheet side="right" title="Edit profile">...</Sheet>
<Sheet side="bottom" title="Edit profile">...</Sheet>
<Sheet side="left" title="Edit profile">...</Sheet>No close button
closeButton={false} hides the X; the footer closes it.
Code
<Sheet title="Choose a plan" closeButton={false}>
<Button slot="trigger">Open sheet</Button>
...
<SheetFooter>
<SheetClose><Button variant="primary">Continue</Button></SheetClose>
</SheetFooter>
</Sheet>Scrolling content
SheetBody scrolls when the content is taller than the sheet; the header and footer stay put. With scroll set to outside the sheet grows with its content and scrolls as one instead. initialFocus puts focus on a static paragraph at the top (tabindex=-1), so the start stays in view rather than jumping to the Accept button.
Code
<Sheet title="Terms of service" initialFocus="#terms-start">
<Button slot="trigger">Read terms</Button>
<SheetBody>
<p id="terms-start" tabindex="-1">The first section takes focus, so it stays in view.</p>
...long text scrolls here...
</SheetBody>
<SheetFooter>...stays in place...</SheetFooter>
</Sheet>
<!-- scroll="outside": the sheet grows with its content and scrolls as one -->
<Sheet side="bottom" scroll="outside" title="Release notes">...</Sheet>Controlled
Bind open to state. The sheet writes false back when it closes itself; onOpenChange reports its own requests; onOpenChangeComplete fires when the animation ends.
Code
<Button onclick={() => state.open = true}>Open from anywhere</Button>
<Sheet open={state.open} side="left" title="Navigation"
onOpenChange={(open) => log(open)}
onOpenChangeComplete={(open) => done(open)}>...</Sheet>Sizes
size sets the side sheet's max width: sm, md (default), lg, xl or full.
Code
<Sheet size="lg" title="Order #1042">...</Sheet>
<Sheet size="sm" title="Quick note">...</Sheet>
<Sheet size="xl" title="Compare plans">...</Sheet>
<Sheet size="full" title="Preview">...</Sheet>Non-modal
modal={false} with dismissOnOutside={false}: no backdrop and no focus trap; the page stays usable while the sheet is open.
Code
<Sheet title="Inspector" modal={false} dismissOnOutside={false}>
<Button slot="trigger">Open inspector</Button>
...
</Sheet>Header parts
SheetHeader, SheetTitle and SheetDescription take rich content and are wired to aria-labelledby and aria-describedby.
Code
<Sheet side="left">
<Button slot="trigger">Menu</Button>
<SheetHeader>
<SheetTitle>Acme <em>Cloud</em></SheetTitle>
<SheetDescription>Signed in as ada@acme.dev</SheetDescription>
</SheetHeader>
<nav>...</nav>
</Sheet>Visually hidden title
A SheetTitle with class="sr-only" is invisible but still names the sheet for screen readers, through aria-labelledby.
Code
<Sheet side="right" size="sm">
<Button slot="trigger">Notifications</Button>
<SheetTitle class="sr-only">Notifications</SheetTitle>
<ul>...</ul>
</Sheet>Right to left
side="end" follows the writing direction: under dir="rtl" it docks to the left edge.
Code
<div dir="rtl">
<Sheet side="end" title="تعديل الملف الشخصي">
<Button slot="trigger">فتح</Button>
...
</Sheet>
</div>Nested
A sheet opened from a sheet. Escape closes the top one. The outer panel gets data-nested-dialog-open and --nested-dialogs, the inner one data-nested, for styling the stack.
Code
<Sheet title="Settings">
<Button slot="trigger">Open settings</Button>
<Sheet side="right" size="sm" title="Advanced">
<Button slot="trigger">Advanced</Button>
...
</Sheet>
</Sheet>
<!-- the outer panel gets data-nested-dialog-open and --nested-dialogs;
the inner one gets data-nested -->Focus and dismissal
initialFocus picks what takes focus on open and returnFocus={false} leaves focus where it lands on close. onEscapeKeyDown and onPointerDownOutside run before a dismiss, and preventDefault keeps the sheet open. dismissOnEscape={false} ignores Escape.
Code
<Sheet title="Invite" initialFocus="#invite-email" returnFocus={false}
onEscapeKeyDown={(e) => log("escape")}
onPointerDownOutside={(e) => { e.preventDefault(); log("outside"); }}>
<Button slot="trigger">Invite</Button>
<input id="invite-email">
<SheetFooter><SheetClose><Button>Done</Button></SheetClose></SheetFooter>
</Sheet>
<Sheet title="Uploading" dismissOnEscape={false}>...</Sheet>Multiple triggers
Buttons anywhere on the page open one controlled sheet and pass what it shows through page state. Focus returns to the button used. Opening it from page state is the caller's change, so onOpenChange reports only the sheet's own close.
Code
<Button onclick={() => edit(state, "Ada Lovelace")}>Edit Ada</Button>
<Button onclick={() => edit(state, "Grace Hopper")}>Edit Grace</Button>
<Sheet open={state.editing} title={state.person}
onOpenChange={(open, reason) => log(reason)}>
...
</Sheet>
function edit(state, person) {
state.person = person; // the payload
state.editing = true; // focus returns to the button used
}Trap focus
modal="trap-focus": Tab stays inside the sheet, but the page keeps scrolling, has no backdrop and takes clicks. A press outside calls onInteractOutside and closes it.
Code
<Sheet modal="trap-focus" title="Filters" onInteractOutside={(e) => count()}>
<Button slot="trigger">Filters</Button>
...
</Sheet>Open from a menu
A menu item opens a controlled sheet. The item is gone by the time it closes, so focus goes back to the menu's button.
Code
<DropdownMenu>
<Button slot="trigger">Project</Button>
<DropdownMenuItem onSelect={() => state.settings = true}>Settings...</DropdownMenuItem>
</DropdownMenu>
<Sheet open={state.settings} title="Project settings">...</Sheet>Focus by interaction type
initialFocus and finalFocus can be functions of how the sheet opened or closed: keyboard, mouse, touch or pen.
Code
<Sheet title="Find a file"
initialFocus={(type) => type === "keyboard" ? "[data-search]" : true}
finalFocus={(type) => type === "keyboard" ? true : "[data-after]"}>
<Button slot="trigger">Find a file</Button>
<SheetBody>
<input aria-label="Folder" value="Documents">
<input aria-label="Search" data-search>
</SheetBody>
</Sheet>Focus on close and focus events
finalFocus names where focus goes on close. onOpenAutoFocus and onCloseAutoFocus run before focus moves, and preventDefault() leaves focus to the handler.
Code
<Sheet title="Add filter" finalFocus="[data-filter-query]">...</Sheet>
<input data-filter-query>
<Sheet title="Share"
onOpenAutoFocus={(e) => { e.preventDefault(); copy.focus(); }}
onCloseAutoFocus={(e) => e.preventDefault()}>
...
</Sheet>No visible title
With no title, label names the sheet through aria-label.
Code
<Sheet label="Quick actions" side="bottom">
<Button slot="trigger">Quick actions</Button>
...
</Sheet>Open on load
open={true} without a binding: the sheet is open when the page loads, then owns its state.
Code
<Sheet open={true} title="Welcome back">
<Button slot="trigger">Welcome</Button>
...
</Sheet>Close after an async submit
A controlled sheet stays open while its form saves, then the page closes it.
Code
<Button onclick={() => state.renaming = true}>Rename project</Button>
<Sheet open={state.renaming} title="Rename project">
<form onsubmit={(e) => save(state, e)}>
<input value={state.name}>
<Button type="submit" variant="primary" loading={state.saving}>Save</Button>
</form>
</Sheet>
async function save(state, e) {
e.preventDefault();
state.saving = true;
await rename(state.name);
state.saving = false;
state.renaming = false;
}One sheet in place of another
Add closes the address sheet and opens a confirmation in its place. OK returns focus to the button that opened the first one (APG).
Code
<Button onclick={() => state.address = true}>Add delivery address</Button>
<Sheet open={state.address} title="Add delivery address">
...fields...
<SheetFooter>
<Button variant="primary" onclick={() => { state.address = false; state.added = true; }}>Add</Button>
</SheetFooter>
</Sheet>
<!-- opened in place of the first: OK returns focus to the page button -->
<Sheet open={state.added} side="bottom" title="Address added" closeButton={false}>
<SheetFooter>
<SheetClose><Button variant="primary">OK</Button></SheetClose>
</SheetFooter>
</Sheet>Non-modal, closes on focus out
modal={false} with the default dismissOnOutside: focus or a press leaving the sheet closes it, and onOpenChange reports focus-out.
Code
<Sheet title="Filters" modal={false} onOpenChange={(open, reason) => log(reason)}>
<Button slot="trigger">Filters</Button>
...
</Sheet>
<!-- focus leaving it closes it, with reason focus-out -->Escape reaches the page
onEscapeKeyDown can call allowPropagation(): Escape closes the sheet and still reaches the page's own key handlers.
Code
<Sheet title="Shortcuts" onEscapeKeyDown={(e) => e.allowPropagation()}>
<Button slot="trigger">Shortcuts</Button>
<SheetBody>
<p>Escape closes this sheet and still reaches the page's own key handler.</p>
</SheetBody>
</Sheet>