# Toast

> Small notifications that stack in a corner, close on their own and pause on hover. Trigger from script or a data attribute.

- **Slug:** `toast`
- **Kind:** component · **Category:** Feedback
- **Source:** original to Viewership UI
- **Docs page:** https://viewership.ai/design/components/toast
- **All files in one bundle:** https://viewership.ai/design/components/toast.bundle.txt
- **Structured JSON:** https://viewership.ai/design/components/toast.json

## Install

1. Import the foundation tokens once (download: https://viewership.ai/design/raw/foundation.css.txt):

```astro
---
// src/layouts/Layout.astro
import "../styles/foundation.css";
---
```

2. Copy these files into `src/components/viewership/toast/`:

   - `Toaster.astro` (raw: https://viewership.ai/design/raw/toast/Toaster.astro.txt)
   - `toast.ts` (raw: https://viewership.ai/design/raw/toast/toast.ts.txt)

npm packages: none.

## Usage

```astro
---
import Toaster from "../components/viewership/toast/Toaster.astro";
---

<Toaster />
```

## Example

The gallery demo, verbatim. Use the import path from Usage instead of `./`.

```astro
---
import Toaster from "./Toaster.astro";
import Button from "../button/Button.astro";
---

<div class="stage">
  <Button variant="primary" data-vs-toast={JSON.stringify({ title: "Report sent", body: "The weekly report went to 4 people on the Northwind team.", tone: "success" })}>
    Send report
  </Button>
  <Button data-vs-toast={JSON.stringify({ title: "Draft saved" })}>Save draft</Button>
  <Button variant="ghost" data-vs-toast={JSON.stringify({ title: "GA4 connection failed", body: "Reconnect the property to refresh this week's numbers.", tone: "error" })}>
    Sync GA4
  </Button>
</div>
<Toaster />

<style>
  .stage {
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    gap: 8px;
    padding: 64px 20px;
    border-radius: var(--vs-radius-window);
    background: var(--vs-canvas);
  }
</style>
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `position` | `"bottom-right" \| "bottom-center" \| "top-right"` | `"bottom-right"` | Corner the toasts stack in. |
| `max` | `number` | `3` | Most toasts shown at once. The oldest closes first. |
| `toast(opts)` | `function` |  | From toast.ts. Options: { title, body?, tone?: "neutral" \| "success" \| "error", duration? }. |
| `data-vs-toast` | `attribute` |  | On any clickable element: the same options as JSON. Shows a toast on click. |
| `vs-toast` | `CustomEvent<ToastOptions>` |  | Dispatch on window to show a toast without importing anything. |

## Source

### `src/components/viewership/toast/Toaster.astro`

```astro
---
/**
 * Toaster: the live region that shows toasts. Render it once per page, then
 * call `toast({ title, body, tone })` from toast.ts, dispatch a `vs-toast`
 * event on window, or give any button a `data-vs-toast` attribute holding the
 * same options as JSON.
 *
 * Toasts close on their own after 4.5 seconds, pause while hovered or
 * focused, and stack up to `max` at a time.
 *
 * @example
 * <Toaster />
 * <button data-vs-toast='{"title":"Draft saved"}'>Save</button>
 */

interface Props {
  position?: "bottom-right" | "bottom-center" | "top-right";
  /** Most toasts shown at once; the oldest closes first. */
  max?: number;
  class?: string;
}

const { position = "bottom-right", max = 3, class: className } = Astro.props;
---

<section class:list={["vs-toaster", `is-${position}`, className]} aria-label="Notifications" data-max={max}>
  <ol class="vs-toaster__list" aria-live="polite" aria-relevant="additions"></ol>
</section>

<style>
  .vs-toaster {
    position: fixed;
    z-index: 1000;
    width: min(calc(100% - 32px), 360px);
    pointer-events: none;
    font-family: var(--vs-font-sans);
    font-stretch: var(--vs-font-stretch);
  }
  .vs-toaster.is-bottom-right {
    right: 16px;
    bottom: 16px;
  }
  .vs-toaster.is-bottom-center {
    left: 50%;
    bottom: 16px;
    transform: translateX(-50%);
  }
  .vs-toaster.is-top-right {
    top: 16px;
    right: 16px;
  }
  .vs-toaster__list {
    display: flex;
    flex-direction: column;
    gap: 8px;
    margin: 0;
    padding: 0;
    list-style: none;
  }
  .vs-toaster.is-top-right .vs-toaster__list {
    flex-direction: column-reverse;
  }
  .vs-toaster :global(.vs-toast) {
    display: grid;
    grid-template-columns: auto 1fr auto;
    align-items: start;
    gap: 12px;
    padding: 14px 12px 14px 16px;
    border-radius: var(--vs-radius-window);
    background: var(--vs-surface);
    color: var(--vs-ink);
    box-shadow: var(--vs-pop-shadow, var(--vs-shadow-overlay));
    pointer-events: auto;
    animation: vs-toast-in var(--vs-duration) var(--vs-ease-out);
  }
  .vs-toaster.is-top-right :global(.vs-toast) {
    animation-name: vs-toast-in-top;
  }
  .vs-toaster :global(.vs-toast.is-leaving) {
    animation: vs-toast-out 200ms ease-in forwards;
  }
  .vs-toaster :global(.vs-toast__dot) {
    width: 8px;
    height: 8px;
    margin-top: 6px;
    border-radius: 50%;
    background: var(--vs-ink-3);
  }
  .vs-toaster :global(.vs-toast.is-success .vs-toast__dot) {
    background: var(--vs-green);
    box-shadow: 0 0 0 4px var(--vs-green-tint);
  }
  .vs-toaster :global(.vs-toast.is-error .vs-toast__dot) {
    background: var(--vs-red);
    box-shadow: 0 0 0 4px var(--vs-red-tint);
  }
  .vs-toaster :global(.vs-toast__title) {
    margin: 0;
    font-size: 0.9375rem;
    font-weight: 600;
    line-height: 1.4;
  }
  .vs-toaster :global(.vs-toast__body) {
    margin: 2px 0 0;
    font-size: 0.875rem;
    line-height: 1.45;
    color: var(--vs-ink-2);
  }
  .vs-toaster :global(.vs-toast__close) {
    display: grid;
    place-items: center;
    width: 26px;
    height: 26px;
    margin: -2px 0 0;
    border: none;
    border-radius: var(--vs-radius-pill);
    background: transparent;
    color: var(--vs-ink-3);
    cursor: pointer;
  }
  .vs-toaster :global(.vs-toast__close:hover) {
    background: var(--vs-hover);
    color: var(--vs-ink);
  }
  .vs-toaster :global(.vs-toast__close:focus-visible) {
    outline: none;
    box-shadow: var(--vs-focus-ring);
  }
  .vs-toaster :global(.vs-toast__close svg) {
    width: 14px;
    height: 14px;
    fill: none;
    stroke: currentColor;
    stroke-width: 2;
    stroke-linecap: round;
  }
  @keyframes vs-toast-in {
    from {
      opacity: 0;
      transform: translateY(12px) scale(0.97);
    }
  }
  @keyframes vs-toast-in-top {
    from {
      opacity: 0;
      transform: translateY(-12px) scale(0.97);
    }
  }
  @keyframes vs-toast-out {
    to {
      opacity: 0;
      transform: scale(0.96);
    }
  }
</style>

<script>
  import type { ToastOptions } from "./toast";

  const CLOSE = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"/></svg>';
  const reduced = () => matchMedia("(prefers-reduced-motion: reduce)").matches;

  function show(root: HTMLElement, opts: ToastOptions) {
    const list = root.querySelector<HTMLOListElement>(".vs-toaster__list")!;
    const max = Number(root.dataset.max) || 3;
    const li = document.createElement("li");
    li.className = `vs-toast is-${opts.tone ?? "neutral"}`;
    // The list is already a polite live region; errors interrupt.
    if (opts.tone === "error") li.setAttribute("role", "alert");

    const dot = document.createElement("span");
    dot.className = "vs-toast__dot";
    dot.setAttribute("aria-hidden", "true");
    const text = document.createElement("div");
    const title = document.createElement("p");
    title.className = "vs-toast__title";
    title.textContent = opts.title;
    text.append(title);
    if (opts.body) {
      const body = document.createElement("p");
      body.className = "vs-toast__body";
      body.textContent = opts.body;
      text.append(body);
    }
    const close = document.createElement("button");
    close.type = "button";
    close.className = "vs-toast__close";
    close.setAttribute("aria-label", "Dismiss");
    close.innerHTML = CLOSE;
    li.append(dot, text, close);

    let timer = 0;
    let left = opts.duration ?? 4500;
    let started = 0;
    const remove = () => {
      clearTimeout(timer);
      if (reduced()) return li.remove();
      li.classList.add("is-leaving");
      li.addEventListener("animationend", () => li.remove(), { once: true });
      // Background tabs may never run the exit animation.
      setTimeout(() => li.remove(), 400);
    };
    const start = () => {
      if (!left) return;
      started = Date.now();
      timer = window.setTimeout(remove, left);
    };
    const pause = () => {
      if (!left) return;
      clearTimeout(timer);
      left = Math.max(800, left - (Date.now() - started));
    };
    close.addEventListener("click", remove);
    li.addEventListener("pointerenter", pause);
    li.addEventListener("pointerleave", start);
    li.addEventListener("focusin", pause);
    li.addEventListener("focusout", start);

    list.append(li);
    const live = [...list.children].filter((c) => !c.classList.contains("is-leaving"));
    if (live.length > max) (live[0] as HTMLElement).querySelector<HTMLButtonElement>(".vs-toast__close")?.click();
    start();
  }

  const roots = () => document.querySelectorAll<HTMLElement>(".vs-toaster");

  if (!(window as any).__vsToaster) {
    (window as any).__vsToaster = true;
    window.addEventListener("vs-toast", (e) => {
      const root = roots()[0];
      if (root) show(root, (e as CustomEvent<ToastOptions>).detail);
    });
    document.addEventListener("click", (e) => {
      const t = (e.target as Element | null)?.closest<HTMLElement>("[data-vs-toast]");
      if (!t) return;
      try {
        window.dispatchEvent(new CustomEvent("vs-toast", { detail: JSON.parse(t.dataset.vsToast!) }));
      } catch {
        /* ignore malformed JSON */
      }
    });
  }
</script>
```

### `src/components/viewership/toast/toast.ts`

```ts
/**
 * Show a toast from any script on a page that renders <Toaster />.
 *
 *   import { toast } from "./toast";
 *   toast({ title: "Report sent", body: "Shared with 4 people.", tone: "success" });
 */
export interface ToastOptions {
  title: string;
  body?: string;
  tone?: "neutral" | "success" | "error";
  /** Milliseconds before it closes. 0 keeps it open until dismissed. Default 4500. */
  duration?: number;
}

export function toast(opts: ToastOptions) {
  window.dispatchEvent(new CustomEvent<ToastOptions>("vs-toast", { detail: opts }));
}
```
