Skip to content

Components / UI

Toast

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 demo

Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.

Measured

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

Baseline · newly available

Chrome 117 · Firefox 129 · Safari 17.5; needs starting-style. Elsewhere: toasts appear without the entrance.

Install

One command adds the integration, the base tokens and every component. Then import what you use.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
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>
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs the shared runtime, toast() and the base tokens.

terminal
npx shadcn@latest add https://moonarc.dev/r/toast.json

The CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.

Source

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.astro
---
/**
 * 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>

Props

PropTypeDefaultDescription
position'bottom-right' | 'bottom-left' | 'bottom-center' | 'top-right' | 'top-left' | 'top-center''bottom-right'Corner.
durationnumber4000Default lifetime in ms; a toast can override it.
visiblenumber3Toasts visible at once; older ones collapse behind, and all show on hover.

Reduced motion

Toasts fade in and out in place: only opacity transitions, so nothing slides, and the stack's offsets change at once.

With ClientRouter

The region is bound through the shared runtime; toasts belong to the page they were raised on.

Why it is built this way

Replaces: Sonner · react-hot-toast · Toast (shadcn). See the migration table.

dialog

Native, animated, zero script.

Escape, backdrop click and focus are the browser's. The scale and the blur are @starting-style and allow-discrete.

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.

newly · Chrome 135 · Firefox 144 · Safari 26.2click

drawer

Drag it down.

Follows the finger; on release, distance or velocity decides. Grab it mid-flight and it obeys.

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.

newly · Chrome 135 · Firefox 144 · Safari 26.2clickpointer

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.

newly · Chrome 125 · Firefox 147 · Safari 26click

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown