Dialog
UIA native modal that scales in over a blurred backdrop and scales out again, closes on Escape, the close button or a backdrop click, and traps focus, all handled by the browser.
Components / UI
Notifications that slide in, stack behind each other, push up as new ones arrive, and expand into a list on hover. Raise one from anywhere with toast() or a DOM event; timers pause while the stack is hovered or focused and while the tab is hidden, and the region is a polite live region. The stack geometry is CSS reading one custom property per toast.
Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.
Adjust · props are custom properties, set inline, so you edit the running instance
JavaScript of its own
1.0 kB raw
605 B gzip.
Uses the shared runtime (1.9 kB raw, once per site). With those included: 2.7 kB raw.
CSS 5.8 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.
Browser support
Chrome 117 · Firefox 129 · Safari 17.5; needs starting-style. Elsewhere: toasts appear without the entrance.
One command adds the integration, the base tokens and every component. Then import what you use.
npx astro add moonarcpnpm astro add moonarcbunx astro add moonarc---
import Toast from '@moonarc/core/Toast';
// once, in the layout
---
<Toast position="bottom-right" duration={4000} />
<script>
import { toast } from '@moonarc/core/toast';
document.querySelector('#save')?.addEventListener('click', () => toast({ title: 'Saved', description: 'Just now.' }));
</script>Owns the file, no dependency. The registry item also installs the shared runtime, toast() and the base tokens.
npx shadcn@latest add https://moonarc.dev/r/toast.jsonThe CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.
The whole component. Self-contained styles in a cascade layer so your classes always win. If you paste it, also copy runtime.ts and toast.ts.
---
/**
* Toast — notifications that stack, push each other up, and expand on hover.
*
* One region per page. Anything on the page raises a toast by dispatching
* `ma:toast` on document (or calling toast() from @moonarc/core/toast).
* Enter is @starting-style, exit is a data attribute plus removal after the
* transition; the stack offset is one custom property per toast, read by
* CSS. The region is the live region; timers pause while it is hovered or
* focused, and while the tab is hidden.
*/
import type { HTMLAttributes } from 'astro/types';
interface Props extends HTMLAttributes<'div'> {
/** Corner. */
position?: 'bottom-right' | 'bottom-left' | 'bottom-center' | 'top-right' | 'top-left' | 'top-center';
/** Default lifetime in ms. */
duration?: number;
/** Toasts visible at once; older ones collapse behind. */
visible?: number;
}
const { position = 'bottom-right', duration = 4000, visible = 3, class: className, ...rest } = Astro.props;
const [side, align] = position.split('-') as ['top' | 'bottom', 'left' | 'right' | 'center'];
---
<div class:list={['ma-toaster', className]} data-ma-toaster data-side={side} data-align={align} data-duration={duration} data-visible={visible} role="region" aria-label="Notifications" aria-live="polite" {...rest}>
<slot />
</div>
<style is:global>
@layer components {
:where(.ma-toaster) {
position: fixed;
z-index: 60;
display: grid;
gap: 0.5rem;
width: min(22rem, calc(100vw - 2rem));
margin: 1rem;
pointer-events: none;
}
:where(.ma-toaster[data-side='bottom']) {
bottom: 0;
align-content: end;
}
:where(.ma-toaster[data-side='top']) {
top: 0;
align-content: start;
}
:where(.ma-toaster[data-align='right']) {
right: 0;
}
:where(.ma-toaster[data-align='left']) {
left: 0;
}
:where(.ma-toaster[data-align='center']) {
left: 50%;
translate: -50% 0;
}
:where(.ma-toast) {
grid-area: 1 / 1;
pointer-events: auto;
border: 1px solid var(--ma-edge);
border-radius: 0.75rem;
padding: 0.75rem 1rem;
background: var(--ma-panel);
color: inherit;
box-shadow: 0 12px 32px -16px rgb(0 0 0 / 0.35);
font-size: 0.875rem;
line-height: 1.4;
/* --ma-i: 0 for the newest, counting back; written by the script */
transform-origin: 50% 100%;
translate: 0 calc(var(--ma-i, 0) * -0.75rem);
scale: calc(1 - var(--ma-i, 0) * 0.05);
opacity: calc(1 - var(--ma-i, 0) * 0.15);
transition:
translate var(--ma-duration) var(--ma-ease),
scale var(--ma-duration) var(--ma-ease),
opacity var(--ma-duration-fast) var(--ma-ease-out);
}
:where(.ma-toaster[data-side='top'] .ma-toast) {
transform-origin: 50% 0;
translate: 0 calc(var(--ma-i, 0) * 0.75rem);
}
:where(.ma-toast:focus-visible) {
outline: 2px solid currentColor;
outline-offset: 2px;
}
:where(.ma-toast[data-hidden]) {
opacity: 0;
pointer-events: none;
}
/* hovered: the stack opens into a list */
:where(.ma-toaster:hover .ma-toast),
:where(.ma-toaster:focus-within .ma-toast) {
grid-area: auto;
translate: 0 0;
scale: 1;
opacity: 1;
}
@starting-style {
:where(.ma-toast) {
translate: 0 var(--ma-travel-enter, 24px);
opacity: 0;
}
}
:where(.ma-toaster[data-side='top'] .ma-toast[data-leaving]),
:where(.ma-toast[data-leaving]) {
opacity: 0;
scale: 0.96;
transition-duration: var(--ma-duration-fast);
transition-timing-function: var(--ma-ease-in);
}
:where(.ma-toast__title) {
font-weight: 600;
}
:where(.ma-toast__desc) {
opacity: 0.7;
margin-top: 0.125rem;
}
@media (prefers-reduced-motion: reduce) {
/* a fade in place: only opacity transitions, so the entrance never slides and the stack's offsets change at once */
:where(.ma-toast) {
transition: opacity var(--ma-duration-fast) linear;
}
}
}
</style>
<script>
import { onMount } from '../lib/runtime';
type Detail = { title: string; description?: string; duration?: number };
onMount<HTMLElement>('[data-ma-toaster]', (region, { signal }) => {
const life = Number(region.dataset.duration ?? 4000);
const visible = Number(region.dataset.visible ?? 3);
const fast = 200;
// the clock stops while a toast is being read (the pointer over the region, the focus in it) and while the tab is hidden
const held = () => document.hidden || region.matches(':hover, :focus-within');
const restack = () => {
const items = [...region.querySelectorAll<HTMLElement>('.ma-toast:not([data-leaving])')].reverse();
items.forEach((t, i) => {
t.style.setProperty('--ma-i', String(i));
if (i >= visible) t.dataset.hidden = '';
else delete t.dataset.hidden;
});
};
const dismiss = (t: HTMLElement) => {
t.dataset.leaving = '';
restack();
setTimeout(() => t.remove(), fast);
};
const show = ({ title, description, duration }: Detail) => {
const t = document.createElement('div');
t.className = 'ma-toast';
// no role="status" of its own: a live region inserted with its text already in it is often not read, so the region,
// live since the page loaded, is the one announcing. Tab reaches the toast, and the focus holds it like the pointer.
t.tabIndex = 0;
t.innerHTML = `<div class="ma-toast__title"></div>${description ? '<div class="ma-toast__desc"></div>' : ''}`;
t.querySelector('.ma-toast__title')!.textContent = title;
if (description) t.querySelector('.ma-toast__desc')!.textContent = description;
region.append(t);
restack();
let left = duration ?? life;
let last = performance.now();
const tick = () => {
if (!t.isConnected) return;
const now = performance.now();
// one tick counts 250 ms at most: a longer gap is time the page did not run (timers stopped in a hidden tab, as
// WebKit on iOS does, or throttled to one a minute), and counting it would drop the toast the moment you are back
if (!held()) left -= Math.min(now - last, 250);
last = now;
if (left <= 0) dismiss(t);
else setTimeout(tick, 100);
};
setTimeout(tick, 100);
t.addEventListener('click', () => dismiss(t), { signal });
};
document.addEventListener('ma:toast', ((e: CustomEvent<Detail>) => show(e.detail)) as EventListener, { signal });
});
</script>| Prop | Type | Default | Description |
|---|---|---|---|
position | 'bottom-right' | 'bottom-left' | 'bottom-center' | 'top-right' | 'top-left' | 'top-center' | 'bottom-right' | Corner. |
duration | number | 4000 | Default lifetime in ms; a toast can override it. |
visible | number | 3 | Toasts visible at once; older ones collapse behind, and all show on hover. |
Toasts fade in and out in place: only opacity transitions, so nothing slides, and the stack's offsets change at once.
ClientRouterThe region is bound through the shared runtime; toasts belong to the page they were raised on.
Replaces: Sonner · react-hot-toast · Toast (shadcn). See the migration table.
A native modal that scales in over a blurred backdrop and scales out again, closes on Escape, the close button or a backdrop click, and traps focus, all handled by the browser.
A bottom sheet that slides up over a scrim and can be dragged closed: it follows the finger, and on release springs back or leaves depending on distance and velocity, interruptible mid-flight.
Raw bytes of JavaScript, from a production build, measured again for every change that can move them.
A popover that opens beside its trigger, flips when there is no room, closes on outside click or Escape, and animates in and out.
Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown