Skip to content
llms.txt
llms.txt

Toast

Open the live Toast demo

Live demo

Toast

Source for this preview

ToastExample.astro
---
import * as m from '../../paraglide/messages';
import type {Locale} from '../../i18n';
const {locale = 'en'} = Astro.props as {locale?: Locale};
---
<section id="toast-demo" class="toast-demo" aria-label="Toast example">
<div class="toast-demo-copy">
<span class="panel-kicker">TOAST / NON-MODAL</span>
<h3>{m.pattern_toast_heading(undefined, {locale})}</h3>
<p>{m.pattern_toast_description(undefined, {locale})}</p>
<button id="toast-create" class="toast-create" type="button">
Create notification <span aria-hidden="true">+</span>
</button>
</div>
<floating-presence-stack
class="toast-viewport"
id="toast-viewport"
limit="6"
timeout="5000"
pause-on="pointer focus"
role="region"
aria-label="Notifications"
aria-live="polite"
aria-relevant="additions"
aria-atomic="false"
>
<template slot="content">
<article class="toast-item" role="status">
<span class="toast-icon" aria-hidden="true">✓</span>
<div class="toast-content">
<strong data-presence-text="title"></strong>
<p data-presence-text="description"></p>
</div>
<button
class="toast-close"
type="button"
data-presence-close
aria-label="Dismiss notification"
>×</button>
</article>
</template>
</floating-presence-stack>
</section>
<script>
import type {FloatingPresenceStackElement} from '@floating-ui-plus/web-components';
import {initializeExample} from './initialize-example';
interface ToastContent {
title: string;
description: string;
}
initializeExample('toast', (scope) => {
const viewport = scope.querySelector<FloatingPresenceStackElement<ToastContent>>(
'#toast-viewport',
);
const createButton = scope.querySelector<HTMLButtonElement>('#toast-create');
if (!viewport || !createButton) return;
let sequence = 0;
createButton.addEventListener('click', () => {
const id = ++sequence;
viewport.add(
{
title: `Notification ${id} created`,
description: 'Your changes have been saved successfully.',
},
{id: String(id)},
);
});
document.addEventListener('keydown', (event) => {
if (event.key !== 'F6' || !viewport.snapshot.records.some((record) => record.open)) {
return;
}
const latestClose = viewport.querySelector<HTMLButtonElement>(
'[data-presence-index="0"] [data-presence-close]',
);
if (!latestClose) return;
event.preventDefault();
latestClose.focus();
});
});
</script>

Open the full demo ↗

This example follows the core behavior of the Base UI Toast specification: a manager creates notifications, a viewport owns their stack, each notification can close itself, and a default timeout removes it automatically. The package default limit is three; the demo sets limit="6" so a taller paused stack with preserved row spacing is visible.

FloatingPresenceStack is headless: it does not paint toast chrome. The Web Component adapter still owns the core host box that interaction depends on — display: block, empty-stack pointer-events: none, and auto while a surface is open or the stack is paused — plus template cloning, value binding, transition presence, and DOM removal. Application CSS owns color, peek, row spacing, and corner placement. Application code owns add(), ARIA, and F6. Pause is opt-in through pause-on; the demo uses pause-on="pointer focus".

Clones default to native popover="manual". Omit top-layer unless the stack must stay in flow (top-layer="none"). Dialog is intentionally avoided because a Toast must not trap focus, inert the page, or lock scrolling.

The Web Component example needs no list or composite wrapper. Native buttons already participate in the document’s Tab order, while F6 moves focus to the newest notification’s close button. Vue exposes the same controller methods through useFloatingPresenceStack() and adds reactive state for template rendering. Pass pauseTarget to the viewport node so Vue can attach the same opt-in pause listeners and --floating-presence-count token.

<floating-presence-stack
class="toast-viewport"
limit="6"
timeout="5000"
pause-on="pointer focus"
role="region"
aria-label="Notifications"
aria-live="polite"
>
<template slot="content">
<article class="toast-item" role="status">
<strong data-presence-text="title"></strong>
<p data-presence-text="description"></p>
<button type="button" data-presence-close>Dismiss</button>
</article>
</template>
</floating-presence-stack>
const viewport = document.querySelector("floating-presence-stack");
viewport.add({
title: "Saved",
description: "Your changes have been saved successfully.",
});

Static limit, timeout, pause-on, resume-delay, exit-duration, and top-layer attributes cover values that belong in markup. configure() is for runtime overrides. Keep landmark and live-region attributes in markup because they describe the rendered region rather than behavior configuration. Leave exit-duration unset so unmount waits for the consumer CSS discrete display / overlay length.

Vue receives the same lifecycle API without a component wrapper:

const viewport = ref<HTMLElement>();
const notices = useFloatingPresenceStack<ToastContent>({
limit: 6,
timeout: 5_000,
pauseTarget: viewport,
pauseOn: "pointer focus",
});
notices.add({title: "Saved", description: "Your changes were saved."});
// Render notices.records.value; call notices.remove(id) after CSS exit.

For a fixed Vue surface that should participate in the browser’s native Top Layer, pair the presence lifecycle with useFloatingTopLayer() on the element you render. The hook already defaults to Popover, matching the Web Component host default:

const surface = ref<HTMLElement | null>(null);
useFloatingTopLayer(surface, computed(() => record.open));
  • A notification remains for five seconds by default.
  • pause-on="pointer focus" pauses every active timeout while the pointer or keyboard focus is inside the host; leaving resumes the remaining duration rather than restarting it. resume-delay (default 100ms) covers the gap between stacked popovers so pointerleave does not immediately fold the stack.
  • The host box, not the page, receives pointer hits in the gaps while paused. Set --floating-presence-hit-span to the current expanded stack height (card size plus row spacing times count - 1); the package reads that token as paused min-height. Do not size the hit box to a max cap.
  • The stack expands while paused so each notification keeps its row spacing and stays readable. Demo CSS peeks only three collapsed cards (min(index, 2)). Paused layout uses a fixed --toast-row (84px), not a compressed amount.
  • The close button starts an exit transition. DOM removal occurs after the transition finishes.
  • The viewport is a labelled landmark with polite live announcements. F6 moves focus to the newest close button without taking focus when a notification first appears; Tab continues through the remaining controls.
  • template[slot="content"] follows the same declarative surface contract as floating-root. Every cloned notification uses the native manual Popover API unless top-layer="none".

CSS owns the visual stack. floating-presence-stack exposes raw state: --floating-presence-index, --floating-presence-count, data-presence-index, data-presence-overflowed, data-presence-paused, data-presence-open, and the transition’s data-status. Use --floating-presence-count for layout math; sibling-count() also sees the Web Component <template> sibling. The application decides how those values affect layout and motion.

The newest surface is index 0. Render clones in chronological order so the newest node is last; sibling-count() - sibling-index() then matches that index without :nth-child rules. --floating-presence-index remains the fallback where those functions are unsupported. Collapsed peek uses min(--toast-index, 2) so only three cards show; paused layout offsets each card by --toast-row so later cards do not cover earlier content.

Vue computes the same custom properties and binds the state attributes:

<div
ref="viewport"
class="toast-viewport"
:data-presence-paused="paused ? '' : undefined"
>
<article
class="toast-item"
:data-status="status"
:data-presence-index="index"
:style="{
'--floating-presence-index': String(index),
}"
/>
</div>

The CSS below is the complete motion contract. Demo toasts are native manual Popovers, so entry uses :popover-open plus @starting-style — the same discrete top-layer path as Popover and Sheet. data-status still covers the Vue first-paint path and the close frame before hidePopover(). Both entry and exit travel downward from the viewport bottom. Restack delay is calc(var(--toast-index) * 35ms), so older surfaces cascade instead of jumping together.

.toast-viewport {
--toast-count: var(--floating-presence-count, 0);
--toast-card: 74px;
--toast-row: 84px;
--toast-stack-span: calc(var(--toast-row) * max(var(--toast-count) - 1, 0));
--floating-presence-hit-span: calc(
var(--toast-card) * min(var(--toast-count), 1) + var(--toast-stack-span)
);
position: fixed;
right: max(20px, env(safe-area-inset-right));
bottom: max(20px, env(safe-area-inset-bottom));
width: min(360px, calc(100vw - 32px));
height: 0;
pointer-events: none;
}
.toast-viewport:has([data-status="open"]),
.toast-viewport:has([data-presence-id]:popover-open),
.toast-viewport[data-presence-paused] {
pointer-events: auto;
}
.toast-viewport[data-presence-paused] {
height: var(--floating-presence-hit-span);
min-height: var(--floating-presence-hit-span, auto);
}
.toast-item {
--floating-presence-index: 0;
--toast-index: var(--floating-presence-index, 0);
--toast-index: calc(sibling-count() - sibling-index());
--toast-count: var(--floating-presence-count, 1);
--toast-peek-index: min(var(--toast-index), 2);
--toast-stagger: 35ms;
position: fixed;
inset: auto max(20px, env(safe-area-inset-right))
max(20px, env(safe-area-inset-bottom)) auto;
z-index: calc(30 - var(--toast-index));
width: min(360px, calc(100vw - 32px));
margin: 0;
pointer-events: auto;
transform-origin: 100% 100%;
opacity: 0;
translate: 0 120%;
scale: 0.98;
transition:
opacity 180ms cubic-bezier(0.16, 1, 0.3, 1),
translate 220ms cubic-bezier(0.32, 0.72, 0, 1),
scale 220ms cubic-bezier(0.32, 0.72, 0, 1),
display 220ms allow-discrete,
overlay 220ms allow-discrete;
}
.toast-item:popover-open {
opacity: calc(1 - var(--toast-peek-index) * 0.14);
translate: 0 calc(var(--toast-peek-index) * -10px);
scale: calc(1 - var(--toast-peek-index) * 0.04);
transition-delay: calc(var(--toast-index) * var(--toast-stagger));
}
@starting-style {
.toast-item:popover-open {
opacity: 0;
translate: 0 120%;
scale: 0.98;
transition-delay: 0s;
}
}
.toast-viewport[data-presence-paused] .toast-item:popover-open {
translate: 0 calc(var(--toast-index) * var(--toast-row) * -1);
scale: 1;
opacity: 1;
}
.toast-item[data-status="initial"],
.toast-item[data-status="close"] {
translate: 0 120%;
scale: 0.98;
opacity: 0;
transition-delay: 0s;
}
.toast-item[data-status="close"] {
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
.toast-item {
--toast-stagger: 0s;
transition-duration: 80ms;
transition-delay: 0s;
}
}

The reduced-motion rule keeps a short presence window so the transition helper can finish removing the node, but removes most of the perceived travel. Keep the CSS duration aligned with unmount: omit exit-duration on the Web Component so it reads the discrete CSS length; Vue still passes that duration to useFloatingTransition().

At the lower level, FloatingPresenceStack emits a closed record immediately. The Web Component adapter keeps its clone mounted until the exit duration ends and then removes the record. Consumers call add() or close(); opt into pause-on or call pause() / resume() from an application policy.