# Toast (Moonarc)

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.

- Import: `import Toast from '@moonarc/core/Toast'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/toast.json`
- Tier C · category ui · trigger click, hover
- Readout: `toast({ title: 'Saved' })`
- Browser support: newly (Chrome 117 · Firefox 129 · Safari 17.5); elsewhere: toasts appear without the entrance
- Measured cost: 1.0 kB raw JS · 605 B gzip · + runtime (with dependencies 2.7 kB raw; CSS 5.8 kB raw)
- Page: https://moonarc.dev/components/toast/

## Props

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

## Usage

```astro
---
// 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>
```

## 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.

## Craft

- Stacked toasts share one grid cell; each is offset and scaled by its index, so the stack is three CSS expressions and no measuring.
- On hover the grid-area returns to auto and the stack becomes a list: the same elements, a different layout, one transition.
- The clock stops while the pointer is over the region, while the focus is in it (Tab reaches every toast) and while the tab is hidden, so nothing disappears while you read it or while you are away.
- The region is the live region (aria-live="polite"), on the page from the first paint; a toast adds its text to it. A new element with role="status" and its text already inside is often not announced at all.
- Exit is a data attribute for 200 ms, then removal: the element leaves the DOM only after the eye has seen it go.

## Replaces

- Sonner
- react-hot-toast
- Toast (shadcn)

## Source

```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>

```
