Skip to content
llms.txt
llms.txt

Sheet

Open the live Sheet demo

Live demo

Sheet

Source for this preview

SheetExample.astro
---
import * as m from '../../paraglide/messages';
import type {Locale} from '../../i18n';
const {locale = 'en'} = Astro.props as {locale?: Locale};
---
<section id="sheet-demo" class="sheet-demo" aria-label="Sheet example">
<div class="sheet-demo-copy">
<span class="panel-kicker">SHEET / DIALOG</span>
<h3>{m.pattern_sheet_heading(undefined, {locale})}</h3>
<p>{m.pattern_sheet_description(undefined, {locale})}</p>
<div class="sheet-side-picker" role="group" aria-label="Sheet side">
<button type="button" name="sheet-side" value="top">Top</button>
<button type="button" name="sheet-side" value="right" aria-pressed="true">Right</button>
<button type="button" name="sheet-side" value="bottom">Bottom</button>
<button type="button" name="sheet-side" value="left">Left</button>
</div>
<floating-root id="sheet-root">
<floating-reference><button class="sheet-trigger" type="button">Open activity sheet <span aria-hidden="true">→</span></button></floating-reference>
<dialog slot="floating" class="sheet-panel" data-side="right" aria-labelledby="sheet-heading" aria-describedby="sheet-description">
<header class="sheet-header"><div><span class="panel-kicker">TODAY / 08:42</span><h4 id="sheet-heading">Activity digest</h4></div><button class="sheet-close" type="button" data-fup-close aria-label="Close sheet">×</button></header>
<p id="sheet-description" class="sheet-description">Review the latest changes before returning to your workspace.</p>
<div class="sheet-scroll-body">
<article class="sheet-item"><span class="sheet-item-mark sheet-item-mark--mint" aria-hidden="true">✓</span><div><strong>Design review completed</strong><p>Spacing and focus states are ready for handoff.</p></div></article>
<article class="sheet-item"><span class="sheet-item-mark sheet-item-mark--cyan" aria-hidden="true">↗</span><div><strong>Three notes were linked</strong><p>The workspace graph now has a clearer next action.</p></div></article>
<article class="sheet-item"><span class="sheet-item-mark sheet-item-mark--coral" aria-hidden="true">!</span><div><strong>One review is waiting</strong><p>Give the launch checklist a final look.</p></div></article>
</div>
<footer class="sheet-footer"><button class="sheet-secondary" type="button" data-fup-close>Not now</button><button class="sheet-primary" type="button" data-fup-close>Open checklist</button></footer>
</dialog>
</floating-root>
</div>
<div class="sheet-demo-art" aria-hidden="true"><div class="sheet-art-orbit"></div><span class="sheet-art-label">FOCUS / CONTEXT / RETURN</span></div>
</section>
<script>
import {FloatingRootElement, click, dismiss} from '@floating-ui-plus/web-components';
import {initializeExample} from './initialize-example';
initializeExample('sheet', (scope) => {
const sheet = FloatingRootElement.query(scope, '#sheet-root');
sheet.configure({
plugins: [click(), dismiss()],
});
const panel = sheet.querySelector<HTMLDialogElement>('[slot="floating"]');
scope.querySelectorAll<HTMLButtonElement>('[name="sheet-side"]').forEach((button) => {
button.addEventListener('click', () => {
const side = button.value || 'right';
panel?.setAttribute('data-side', side);
scope.querySelectorAll('[name="sheet-side"]').forEach((candidate) => candidate.setAttribute('aria-pressed', String(candidate === button)));
});
});
});
</script>

Open the full demo ↗

The example uses the native <dialog> top layer as a modal Sheet. Choose top, right, bottom, or left before opening it. Focus moves into the Sheet, the page behind it becomes inert, and focus returns to the trigger after it closes.

dismiss() treats a press on the native dialog backdrop as an outside press. Escape and the explicit close controls use the same close-request path. Set outsidePress: false when a workflow must require an explicit choice.

<floating-root>
<floating-reference><button>Open sheet</button></floating-reference>
<dialog slot="floating" data-side="right" aria-labelledby="sheet-title">
<h2 id="sheet-title">Activity</h2>
<button data-fup-close>Close</button>
</dialog>
</floating-root>
root.configure({ plugins: [click(), dismiss()] });

An edge Sheet is positioned by viewport insets rather than reference coordinates. A direct native dialog skips Floating UI positioning styles, so the edge CSS is the sole placement contract. Vue uses the same composition:

<FloatingRoot v-model:open="open" :plugins="plugins">
<FloatingContent as="dialog" class="sheet" :data-side="side" />
</FloatingRoot>

Native dialog transitions should use the platform’s discrete transition support, so close() remains immediate while CSS keeps the visual surface and backdrop present until the transition completes.

.sheet {
position: fixed;
margin: 0;
--surface-motion-start-x: 100%;
--surface-motion-start-y: 0px;
--surface-motion-enter-duration: 240ms;
--surface-motion-exit-duration: 180ms;
--surface-motion-easing: cubic-bezier(0.32, 0.72, 0, 1);
opacity: 0;
translate: var(--surface-motion-start-x) var(--surface-motion-start-y);
transition:
opacity var(--surface-motion-exit-duration) var(--surface-motion-easing),
translate var(--surface-motion-exit-duration) var(--surface-motion-easing),
display var(--surface-motion-exit-duration) allow-discrete,
overlay var(--surface-motion-exit-duration) allow-discrete;
}
.sheet:open {
opacity: 1;
translate: 0 0;
transition-duration: var(--surface-motion-enter-duration);
}
.sheet[data-side="top"] {
inset: 0 0 auto 0;
width: 100%;
height: min(34rem, 78dvh);
--surface-motion-start-x: 0px;
--surface-motion-start-y: -100%;
}
.sheet[data-side="right"] {
inset: 0 0 0 auto;
width: min(25rem, 100vw);
height: 100dvh;
}
.sheet[data-side="bottom"] {
inset: auto 0 0 0;
width: 100%;
height: min(34rem, 78dvh);
--surface-motion-start-x: 0px;
--surface-motion-start-y: 100%;
}
.sheet[data-side="left"] {
inset: 0 auto 0 0;
width: min(25rem, 100vw);
height: 100dvh;
--surface-motion-start-x: -100%;
--surface-motion-start-y: 0px;
}
.sheet::backdrop {
background: rgb(0 0 0 / 0%);
transition:
background-color var(--surface-motion-exit-duration)
var(--surface-motion-easing),
display var(--surface-motion-exit-duration) allow-discrete,
overlay var(--surface-motion-exit-duration) allow-discrete;
}
.sheet:open::backdrop {
background: rgb(0 0 0 / 16%);
}
@starting-style {
.sheet:open {
opacity: 0;
translate: var(--surface-motion-start-x) var(--surface-motion-start-y);
}
.sheet:open::backdrop {
background: rgb(0 0 0 / 0%);
}
}
@media (prefers-reduced-motion: reduce) {
.sheet {
--surface-motion-start-x: 0px;
--surface-motion-start-y: 0px;
transition-duration: 80ms;
}
}

Each direction owns both its physical viewport inset and its travel vector. Using physical top, right, bottom, and left properties makes the edge contract explicit. If a shared component-layer rule uses !important, keep these directional overrides in that same cascade layer so a base inset cannot win unexpectedly.

The display and overlay transitions are required for native exit motion. They keep the closing dialog in layout and in the top layer while opacity, translate, and ::backdrop finish. Without a supported discrete transition, the Sheet still closes correctly; it simply closes without the exit motion.

Both adapters render the same native <dialog> and use the same side, open-state, backdrop, dismiss(), and reduced-motion CSS.

For the general native Popover/dialog lifecycle and browser support notes, see Entry and exit animation.