Sheet
An edge-anchored overlay built on the native `<dialog>`: a drawer that slides in from any of the four sides, safe-area aware and swipe-dismissible.
Live demo
Sheet is the drawer half of the overlay family, and the touch-app counterpart to Dialog. It wraps the same platform <dialog> element, so the top-layer scrim, the focus trap, focus restore on close, Escape-to-dismiss, and the role/aria-modal semantics all come from the browser rather than re-implemented JavaScript; what it adds is the axis Dialog has no answer for.
A side (top, right, bottom, left) anchors the panel to a viewport edge and sets the direction it slides and swipes along; a size (sm, md, lg, full) sets its extent across that edge — a height for a top or bottom sheet, a width for a left or right one. It is safe-area aware the way MobileShell is: the panel grows by its edge's env(safe-area-inset-*) and pads that inset back out of its content, so it meets the hardware edge without any content sliding under a notch or a home indicator. non-modal opens it beside a live page — no scrim, no focus trap, the rest of the app still interactive — which is what a persistent side drawer or an inspector panel wants; the keyboard Escape path is wired explicitly there, since the platform only honors it for a modal dialog. A pointer swipe from the grabber or the header dismisses it, layered strictly on top of the keyboard paths (never in place of them). Its chrome — the panel, the drag handle and grabber, the header, the close button — renders through the component.sheet fragment, so a mod can reshape the whole drawer.
When to use
How this component composes with the rest of the set.
Props
11 props, straight from the manifest.
| Prop | Type | Default | Bindings | Description |
|---|---|---|---|---|
Appearance
Variants
bottom
Pinned to the bottom edge, sliding up. The default, and the phone-native posture.
top
Pinned to the top edge, sliding down. For a notification tray or a search bar.
left
Pinned to the left edge, sliding in. The classic navigation drawer.
right
Pinned to the right edge, sliding in. For an inspector or a detail pane.
Sizes
sm
Compact: 28dvh tall, or 16rem wide.
md
Default: 45dvh tall, or 22rem wide.
lg
Roomy: 72dvh tall, or 30rem wide.
full
The whole edge-to-edge screen, insets included.
States
open
Shown via showModal() (top layer, scrim painted by ::backdrop) or show() under non-modal. The panel slides in from its edge.
dragging
A swipe is in flight; the panel tracks the pointer, so its easing is suspended and text selection is off.
non-modal
Opened beside a live page: no scrim, no focus trap, and a stacking position of its own since it is not in the top layer.
close-hover
Pointer over the close button; overlay paints the hover tint and the icon brightens.
close-focus-visible
Keyboard focus on the close button; a token ring plus the transparent outline promoted in forced-colors mode.
Anatomy
The named parts that make up the component, with their selectors.
sheet
The native <dialog> pinned to its edge by an auto margin: the elevated panel above the scrim.
scrim
The native ::backdrop pseudo-element dimming the page behind a modal sheet; a non-modal sheet paints none.
handle
The drag region at the sheet's inner edge — the swipe-to-dismiss hit area, touch-action: none so a drag never becomes a scroll.
grabber
The bar drawn inside the handle: the visible affordance that the sheet can be dragged. Horizontal on a top/bottom sheet, vertical on a left/right one.
panel
The content column inside the sheet, holding the header, the scrolling body, and the footer beside the handle.
header
The top region carrying the title slot and the close button, separated by a hairline. Doubles as a swipe surface.
body
The scrolling content region between header and footer; overscroll is contained so a flick never scrolls the page behind.
footer
The bottom region for actions, right-aligned with a hairline above. Collapses when the slot is empty.
close
The default dismiss button in the header corner, drawn in currentColor.
Tokens & coverage
What the component consumes, checked live against what the algorithm produces.
Live coverage check against the xtyle-default register
(derive(xtyleDefault, { anchors }) →
coverComponent(manifest, register)). Every token this component
consumes must be a key the algorithm produces.
--border-normal
--border-thick
--border-thin
--duration-base
--duration-fast
--ease-emphasized
--ease-standard
--elevation-5
--fg-0
--fg-1
--fg-2
--font-sans
--layer-overlay
--leading-normal
--leading-tight
--line
--radius-full
--radius-lg
--radius-sm
--ring
--scrim
--space-2
--space-3
--space-4
--space-5
--state-hover
--state-press
--surface-overlay
--surface-overlay-border
--text-body
--text-lg
--weight-semibold
Slots
The sheet's body content. Scrolls on its own when it overflows.
Custom header content, replacing the generated title. Falls back to the heading text; pair it with label to give the sheet an accessible name.
Footer actions, right-aligned. Collapses entirely when unfilled.
Accessibility
Code
Bottom filter sheet
The phone-native posture: a titled tray that rises from the bottom edge, dismissible by grabber, Escape, scrim, or the action row.
<xtyle-button variant="solid" tone="accent" onclick="document.getElementById('filters').showModal()">
Filters
</xtyle-button>
<xtyle-sheet id="filters" side="bottom" size="md" heading="Filters">
<p>Narrow the results. The body scrolls; the header and the action row stay put.</p>
<div slot="footer">
<xtyle-button variant="ghost" tone="neutral" onclick="document.getElementById('filters').close()">
Reset
</xtyle-button>
<xtyle-button variant="solid" tone="accent" onclick="document.getElementById('filters').close()">
Apply
</xtyle-button>
</div>
</xtyle-sheet>
<script lang="ts">
import { Button, Sheet } from "@xtyle/svelte";
let filters = $state(false);
let nav = $state(false);
</script>
<Button variant="solid" tone="accent" onclick={() => (filters = true)}>Filters</Button>
<Button variant="outline" tone="neutral" onclick={() => (nav = true)}>Menu</Button>
<Sheet bind:open={filters} side="bottom" size="md" heading="Filters">
<p>Narrow the results. Drag the grabber down, press Escape, or tap the scrim to dismiss.</p>
{#snippet footer()}
<Button variant="ghost" tone="neutral" onclick={() => (filters = false)}>Reset</Button>
<Button variant="solid" tone="accent" onclick={() => (filters = false)}>Apply</Button>
{/snippet}
</Sheet>
<Sheet bind:open={nav} side="left" size="sm" heading="Navigate" noGrabber>
<nav>…</nav>
</Sheet>
---
import { Button, Sheet } from "@xtyle/astro";
---
<Button variant="solid" tone="accent" id="open-filters">Filters</Button>
<Sheet id="filters" side="bottom" size="md" heading="Filters">
<p>Narrow the results.</p>
<div slot="footer">
<Button variant="ghost" tone="neutral" id="reset-filters">Reset</Button>
<Button variant="solid" tone="accent" id="apply-filters">Apply</Button>
</div>
</Sheet>
<script>
const sheet = document.getElementById("filters") as HTMLElement & {
showModal(): void;
close(): void;
};
document.getElementById("open-filters")?.addEventListener("click", () => sheet.showModal());
document.getElementById("reset-filters")?.addEventListener("click", () => sheet.close());
document.getElementById("apply-filters")?.addEventListener("click", () => sheet.close());
</script>