Skip to main content
xriptxr

Sheet

Info:Overlay
32

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.

drawer bottom sheet side panel slide-over off-canvas tray swipe modal

Live demo

live · @xtyle/astro

Sheet

Open a drawer

Each button opens a real <dialog> anchored to a viewport edge — scrim, focus trap, and Esc-to-close come from the platform. Drag the grabber (or the header) toward the edge to dismiss.

Sides

Sizes

Posture

Filters

The phone-native posture. The panel grows by the bottom safe-area inset and pads it back out of the content, so it meets the home indicator without hiding anything behind it.

Only show available Include archived

Navigate

The classic navigation drawer. Its grabber turns vertical on a left/right sheet, and the swipe runs along the horizontal axis.

Inspector

A modal detail pane. Every surface here is derived from the same algorithm powering this page — the overlay panel, the hairlines, the scrim, and the focus ring are all materialized tokens.

Notifications

A top sheet pulls down like a notification shade, absorbing the top safe-area inset so its content clears a notch. Its handle sits at the bottom edge — the direction you swipe to send it away.

size="sm"

28dvh tall on a bottom sheet, 16rem wide on a side one.

size="lg"

72dvh tall on a bottom sheet, 30rem wide on a side one.

size="full"

The whole edge-to-edge screen, insets included — a full-screen takeover that still slides in from its edge.

Live inspector

non-modal opens beside a live page: no scrim, no focus trap, no inert background. Click around the demo behind this panel — it still works. Escape closes it whenever focus is inside.

Confirm

noGrabber drops the drag affordance and noSwipe drops the gesture, for a sheet that must be answered rather than flicked away. Escape and the close button still work — a gesture was never the only way out.

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.

Reach for Sheet over Dialog whenever the overlay belongs to an edge rather than the center: a phone filter tray (side="bottom"), a navigation drawer (side="left"), an inspector (side="right"), a notification shade (side="top").
Pair it with MobileShell and BottomNav for the full touch-app frame: the shell holds the bar and the nav, and a bottom Sheet rises over both.
Set non-modal for a drawer that sits alongside a still-usable page (an inspector, a persistent filter rail). Leave it modal for anything that demands an answer before the app continues.
Set --sheet-block / --sheet-inline on ::part(sheet) for an exact extent the four size steps do not cover (xtyle-sheet::part(sheet) { --sheet-inline: 26rem; }).
Drop noGrabber on a left/right drawer that reads better without a bar; the swipe still works from the header. Drop noSwipe too when the sheet must not be dismissible by gesture (a blocking confirm).

Props

11 props, straight from the manifest.

PropTypeDefaultBindingsDescription
open boolean false
html svelte astro
Whether the sheet is shown. Set it true to open; the wrappers bind it two-way.
side SheetSide
top right bottom left
bottom
html svelte astro
Which viewport edge the sheet is anchored to; also the axis it slides and swipes along.
size SheetSize
sm md lg full
md
html svelte astro
The sheet's extent across its anchored edge: a height for `top` / `bottom`, a width for `left` / `right`. For an exact extent, set `--sheet-block` / `--sheet-inline` on `::part(sheet)`.
nonModal boolean false
html svelte astro
Opens the sheet beside a live page — no scrim, no focus trap, no inert background — for a persistent drawer or inspector. `Escape` still closes it whenever focus is inside.
heading string
html svelte astro
Title text rendered in the header and wired to the sheet via `aria-labelledby`.
label string
html svelte astro
Accessible name applied as `aria-label`: the way to name a sheet that supplies its own `header` slot instead of a `heading`.
labelledby string
html svelte astro
Id of an external element naming the sheet. Overrides the generated heading id.
closeLabel string Close
html svelte astro
Accessible label for the built-in close button.
noCloseButton boolean false
html svelte astro
Suppresses the built-in close button when the sheet supplies its own dismiss control.
noGrabber boolean false
html svelte astro
Suppresses the drag handle and its grabber bar. The swipe gesture still works from the header; pair with `noSwipe` to drop the gesture entirely.
noSwipe boolean false
html svelte astro
Disables swipe-to-dismiss. The keyboard, close-button, and scrim paths are untouched — the gesture was never the only way out.

Appearance

Variants

bottom

.xtyle-sheet--bottom

Pinned to the bottom edge, sliding up. The default, and the phone-native posture.

top

.xtyle-sheet--top

Pinned to the top edge, sliding down. For a notification tray or a search bar.

left

.xtyle-sheet--left

Pinned to the left edge, sliding in. The classic navigation drawer.

right

.xtyle-sheet--right

Pinned to the right edge, sliding in. For an inspector or a detail pane.

Sizes

sm

.xtyle-sheet--sm

Compact: 28dvh tall, or 16rem wide.

md

default
.xtyle-sheet

Default: 45dvh tall, or 22rem wide.

lg

.xtyle-sheet--lg

Roomy: 72dvh tall, or 30rem wide.

full

.xtyle-sheet--full

The whole edge-to-edge screen, insets included.

States

open

.xtyle-sheet[open]

Shown via showModal() (top layer, scrim painted by ::backdrop) or show() under non-modal. The panel slides in from its edge.

dragging

.xtyle-sheet[data-dragging]

A swipe is in flight; the panel tracks the pointer, so its easing is suspended and text selection is off.

non-modal

.xtyle-sheet--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

.xtyle-sheet__close:hover

Pointer over the close button; overlay paints the hover tint and the icon brightens.

close-focus-visible

.xtyle-sheet__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

.xtyle-sheet

The native <dialog> pinned to its edge by an auto margin: the elevated panel above the scrim.

--font-sans --text-body --leading-normal --fg-0 --surface-overlay --surface-overlay-border --border-thin --radius-lg --elevation-5 --duration-base --ease-emphasized

scrim

.xtyle-sheet::backdrop

The native ::backdrop pseudo-element dimming the page behind a modal sheet; a non-modal sheet paints none.

--scrim --duration-base --ease-emphasized

handle

.xtyle-sheet__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.

--space-2 --space-3 --space-4

grabber

.xtyle-sheet__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.

--fg-2 --radius-full

panel

.xtyle-sheet__panel

The content column inside the sheet, holding the header, the scrolling body, and the footer beside the handle.

header

.xtyle-sheet__header

The top region carrying the title slot and the close button, separated by a hairline. Doubles as a swipe surface.

--space-3 --space-4 --space-5 --border-thin --line

body

.xtyle-sheet__body

The scrolling content region between header and footer; overscroll is contained so a flick never scrolls the page behind.

--space-5 --fg-1

footer

.xtyle-sheet__footer

The bottom region for actions, right-aligned with a hairline above. Collapses when the slot is empty.

--space-2 --space-4 --space-5 --border-thin --line

close

.xtyle-sheet__close

The default dismiss button in the header corner, drawn in currentColor.

--fg-2 --fg-0 --radius-sm --duration-fast --ease-standard --state-hover --state-press --border-normal --border-thick --ring

Tokens & coverage

What the component consumes, checked live against what the algorithm produces.

Success:fully covered 32/32 consumed tokens produced default register: 305 tokens

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

default
html svelte astro

The sheet's body content. Scrolls on its own when it overflows.

header
html svelte astro

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
html svelte astro

Footer actions, right-aligned. Collapses entirely when unfilled.

Accessibility

Built on the native <dialog> element, so role="dialog", the focus trap, focus restore on close, Escape-to-dismiss, and (when modal) aria-modal="true" all come from the browser.
showModal() places the sheet in the top layer and renders the ::backdrop scrim; a click on the scrim closes it. non-modal opens with show() — no scrim, no trap — and the element wires Escape itself, since the platform only closes a modal dialog on that key.
A heading (or explicit labelledby) wires the sheet to its title via aria-labelledby; a header-slot sheet names itself with label (an aria-label) instead, since the IDREF cannot cross into the slotted light DOM. The binding warns at runtime when none is present.
Swipe-to-dismiss is strictly additive, never a substitute: every sheet stays dismissible by Escape, by the close button, and by the scrim, so a pointer gesture is never the only way out. The grabber is aria-hidden and takes no tab stop, because it duplicates a path already reachable.
The gesture only starts on the grabber or the header's own chrome — never in the scrolling body, and never on an interactive control inside the header — so a drag can never eat a scroll or swallow a button press.
The built-in close button carries an aria-label (default "Close", overridable via closeLabel) and its glyph is aria-hidden; keyboard focus on it paints a token ring plus the transparent outline forced-colors mode promotes to a real system outline.
The slide-in honors prefers-reduced-motion: the transform transition is dropped and the sheet simply appears at its edge.
Closing (by Escape, the close button, a scrim click, a swipe, or close()) fires a close event and clears the open state.

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>