Toast + Notifications

Click a button to emit a toast. Hover an active toast to pause its auto-dismiss timer.

Demo

Static authored example

(Not connected to the runtime — just shows the visual.)

  1. Saved

    Your changes were saved.

  2. Couldn't reach server

    Check your connection and try again.

  3. 3 items moved

  
/*
 * Toast + Notifications — overlays
 *
 * Sonner-style stack manager. A single Notifications container renders
 * at a corner; toasts append at the top and visually push older toasts
 * down with subtle scaling for depth. Auto-dismiss timer; hover pauses.
 *
 *   <ol class="notifications" data-position="bottom-right" aria-live="polite" aria-label="Notifications"></ol>
 *
 *   <li class="toast -success" data-state="enter">
 *     <span class="toast__icon" aria-hidden="true">✓</span>
 *     <div class="toast__content">
 *       <strong class="toast__title">Saved</strong>
 *       <p class="toast__description">Your changes were saved.</p>
 *     </div>
 *     <button class="toast__dismiss" aria-label="Dismiss">×</button>
 *   </li>
 *
 * The Notifications container is fixed-positioned; toasts are children
 * arranged in a column. Toast (standalone) can also be used outside
 * Notifications — it just won't get stack management.
 */

@layer malevich.components {
  /* ---- Notifications container ---- */

  .notifications {
    --notifications-gap: var(--space-gap-elements-s);
    --notifications-inset: var(--space-inset-block-m);

    list-style: none;
    margin: 0;
    padding: 0;

    position: fixed;
    z-index: 1100;

    display: flex;
    flex-direction: column-reverse;
    gap: var(--notifications-gap);

    inline-size: var(--notifications-width);
    max-inline-size: calc(100vw - var(--notifications-inset) * 2);
    pointer-events: none;
  }

  .notifications > .toast {
    pointer-events: auto;
  }

  .notifications[data-position="bottom-right"] {
    inset-block-end: var(--notifications-inset);
    inset-inline-end: var(--notifications-inset);
  }
  .notifications[data-position="bottom-left"] {
    inset-block-end: var(--notifications-inset);
    inset-inline-start: var(--notifications-inset);
  }
  .notifications[data-position="top-right"] {
    inset-block-start: var(--notifications-inset);
    inset-inline-end: var(--notifications-inset);
    flex-direction: column;
  }
  .notifications[data-position="top-left"] {
    inset-block-start: var(--notifications-inset);
    inset-inline-start: var(--notifications-inset);
    flex-direction: column;
  }

  /* ---- Toast ---- */

  .toast {
    display: flex;
    align-items: flex-start;
    gap: var(--space-gap-elements-s);

    padding: var(--space-inset-block-m);

    background-color: var(--color-surface-float);
    color: var(--color-ink-strong);
    border: var(--border-width-hairline) solid var(--color-border-default);
    border-radius: var(--radius-card);
    box-shadow: var(--shadow-float);

    transition:
      transform var(--motion-fast) var(--motion-easing-default),
      opacity var(--motion-fast) var(--motion-easing-default);
  }

  .toast[data-state="enter"] {
    transform: translateY(var(--space-inset-element-m));
    opacity: 0;
    animation: malevich-toast-in var(--motion-base) var(--motion-easing-default) forwards;
  }

  .toast[data-state="exit"] {
    transform: translateX(0);
    opacity: 1;
    animation: malevich-toast-out var(--motion-base) var(--motion-easing-default) forwards;
  }

  .toast__icon {
    flex-shrink: 0;
    inline-size: var(--size-control-s);
    block-size: var(--size-control-s);
    display: inline-flex;
    align-items: center;
    justify-content: center;
    color: var(--color-ink-regular);
  }

  .toast__content {
    flex: 1;
    min-inline-size: 0;
  }

  .toast__title {
    display: block;
    color: var(--color-ink-strong);
    font-family: var(--font-body-regular-family);
    font-size: var(--font-body-regular-size);
    font-weight: 600;
    line-height: var(--font-body-regular-line-height);
    letter-spacing: var(--font-body-regular-letter-spacing);
  }

  .toast__description {
    margin: 0;
    color: var(--color-ink-regular);
    font-family: var(--font-body-support-family);
    font-size: var(--font-body-support-size);
    font-weight: var(--font-body-support-weight);
    line-height: var(--font-body-support-line-height);
    letter-spacing: var(--font-body-support-letter-spacing);
  }

  .toast__title + .toast__description {
    margin-block-start: var(--space-gap-elements-s);
  }

  .toast__dismiss {
    appearance: none;
    -webkit-appearance: none;
    margin: 0;
    padding: 0;
    background: transparent;
    border: 0;
    cursor: pointer;
    color: var(--color-ink-subtle);
    inline-size: var(--size-control-s);
    block-size: var(--size-control-s);
    display: inline-flex;
    align-items: center;
    justify-content: center;
    border-radius: var(--radius-button);
    flex-shrink: 0;
  }

  .toast__dismiss:hover {
    color: var(--color-ink-strong);
    background-color: var(--color-surface-canvas);
  }

  .toast__dismiss:focus-visible {
    outline: var(--border-width-focus) solid var(--color-accent);
    outline-offset: var(--border-width-hairline);
  }

  /* Status variants — color the left edge */

  .toast.-info {
    border-inline-start: var(--border-width-emphasis) solid var(--color-info);
  }
  .toast.-info .toast__icon { color: var(--color-info); }

  .toast.-success {
    border-inline-start: var(--border-width-emphasis) solid var(--color-success);
  }
  .toast.-success .toast__icon { color: var(--color-success); }

  .toast.-warning {
    border-inline-start: var(--border-width-emphasis) solid var(--color-warning);
  }
  .toast.-warning .toast__icon { color: var(--color-warning); }

  .toast.-danger {
    border-inline-start: var(--border-width-emphasis) solid var(--color-danger);
  }
  .toast.-danger .toast__icon { color: var(--color-danger); }

  @keyframes malevich-toast-in {
    to { transform: translateY(0); opacity: 1; }
  }

  @keyframes malevich-toast-out {
    to { transform: translateX(110%); opacity: 0; }
  }

  @media (prefers-reduced-motion: reduce) {
    .toast[data-state="enter"],
    .toast[data-state="exit"] {
      animation: none;
      opacity: 1;
      transform: none;
    }
  }
}
// Toast + Notifications — runtime.
//
// Sonner-inspired API:
//   const notify = createNotifications();
//   notify.show({ title: "Saved", variant: "success" });
//
// If the host page has not authored a .notifications container, the
// runtime creates one lazily at bottom-right with aria-live="polite".

export type ToastVariant = "info" | "success" | "warning" | "danger" | "neutral";

export interface ToastOptions {
  /** Short bold label. */
  title?: string;
  /** Longer description below the title. */
  description?: string;
  /** Visual + ARIA color tone. Default: neutral. */
  variant?: ToastVariant;
  /** Ms before auto-dismiss. Default: 5000. Pass 0 to keep until dismissed manually. */
  duration?: number;
  /** Element / icon glyph to render in the icon slot. Default: per variant. */
  icon?: string;
  /** Override the position of the Notifications container. Default: bottom-right. */
  position?: "bottom-right" | "bottom-left" | "top-right" | "top-left";
}

const DEFAULT_DURATION = 5000;
const EXIT_ANIMATION_MS = 220;

const ICON_DEFAULTS: Record<ToastVariant, string> = {
  info: "ℹ",
  success: "✓",
  warning: "⚠",
  danger: "⚠",
  neutral: "•",
};

interface ToastInstance {
  id: number;
  element: HTMLLIElement;
  timer?: number;
  remaining: number;
  startedAt: number;
  duration: number;
}

export interface NotificationsAPI {
  /** Show a toast; returns its id (number). */
  show(opts: ToastOptions): number;
  /** Dismiss a toast by id. */
  dismiss(id: number): void;
  /** Dismiss every visible toast. */
  dismissAll(): void;
  /** The container element (for advanced uses). */
  container: HTMLOListElement;
}

function ensureContainer(
  position: ToastOptions["position"] = "bottom-right",
): HTMLOListElement {
  let container = document.querySelector<HTMLOListElement>(
    `ol.notifications[data-position="${position}"]`,
  );
  if (container) return container;

  container = document.createElement("ol");
  container.className = "notifications";
  container.dataset.position = position;
  container.setAttribute("aria-live", "polite");
  container.setAttribute("aria-label", "Notifications");
  document.body.appendChild(container);
  return container;
}

function buildToast(id: number, opts: ToastOptions): HTMLLIElement {
  const variant = opts.variant ?? "neutral";
  const li = document.createElement("li");
  li.className = `toast -${variant}`;
  li.dataset.toastId = String(id);
  li.dataset.state = "enter";

  const icon = document.createElement("span");
  icon.className = "toast__icon";
  icon.setAttribute("aria-hidden", "true");
  icon.textContent = opts.icon ?? ICON_DEFAULTS[variant];
  li.appendChild(icon);

  const content = document.createElement("div");
  content.className = "toast__content";

  if (opts.title) {
    const title = document.createElement("strong");
    title.className = "toast__title";
    title.textContent = opts.title;
    content.appendChild(title);
  }

  if (opts.description) {
    const desc = document.createElement("p");
    desc.className = "toast__description";
    desc.textContent = opts.description;
    content.appendChild(desc);
  }

  li.appendChild(content);

  const dismiss = document.createElement("button");
  dismiss.type = "button";
  dismiss.className = "toast__dismiss";
  dismiss.setAttribute("aria-label", "Dismiss");
  dismiss.textContent = "×";
  li.appendChild(dismiss);

  return li;
}

let idCounter = 0;
const instances = new Map<number, ToastInstance>();

function bindHoverPause(api: NotificationsAPI, inst: ToastInstance): void {
  const { element } = inst;
  element.addEventListener("mouseenter", () => {
    if (inst.timer === undefined) return;
    window.clearTimeout(inst.timer);
    inst.timer = undefined;
    inst.remaining -= Date.now() - inst.startedAt;
  });
  element.addEventListener("mouseleave", () => {
    if (inst.remaining <= 0 || inst.duration === 0) return;
    inst.startedAt = Date.now();
    inst.timer = window.setTimeout(() => api.dismiss(inst.id), inst.remaining);
  });
}

/**
 * Create (or reuse) the notifications stack and return its API.
 * Multiple createNotifications() calls with the same position return
 * the same underlying container.
 */
export function createNotifications(
  options: { position?: ToastOptions["position"] } = {},
): NotificationsAPI {
  const container = ensureContainer(options.position);

  const api: NotificationsAPI = {
    container,

    show(opts) {
      const id = ++idCounter;
      const duration = opts.duration ?? DEFAULT_DURATION;
      const element = buildToast(id, opts);
      container.appendChild(element);

      const inst: ToastInstance = {
        id,
        element,
        remaining: duration,
        startedAt: Date.now(),
        duration,
      };

      const dismissBtn = element.querySelector<HTMLButtonElement>(".toast__dismiss");
      dismissBtn?.addEventListener("click", () => api.dismiss(id));

      if (duration > 0) {
        inst.timer = window.setTimeout(() => api.dismiss(id), duration);
      }

      bindHoverPause(api, inst);
      instances.set(id, inst);
      return id;
    },

    dismiss(id) {
      const inst = instances.get(id);
      if (!inst) return;
      if (inst.timer !== undefined) {
        window.clearTimeout(inst.timer);
      }
      inst.element.dataset.state = "exit";
      window.setTimeout(() => {
        inst.element.remove();
        instances.delete(id);
      }, EXIT_ANIMATION_MS);
    },

    dismissAll() {
      for (const id of instances.keys()) api.dismiss(id);
    },
  };

  return api;
}
# Toast — AGENTS.md

> Auto-generated from `toast.docs.md` + the modifier applicability matrix.
> Edit those source files; this file regenerates on build.

## Identity

- **Component:** `toast`
- **Layer:** overlays
- **Status:** stable
- **Last updated:** 2026-05-19

## Summary

- Confirm successful operations ("Saved", "Copied").

## Variants

| Variant   | Class               | Default icon |
|-----------|---------------------|--------------|
| Neutral   | `.toast`            | •            |
| Info      | `.toast.-info`      | ℹ            |
| Success   | `.toast.-success`   | ✓            |
| Warning   | `.toast.-warning`   | ⚠            |
| Danger    | `.toast.-danger`    | ⚠            |

Variants color the left edge and the icon; the rest stays neutral so
the message reads clearly against the surface.

## Modifier applicability

Per ADR-0007. Modifiers attach via `data-{category}="value"` attributes.

| Category | Accepts |
|---|---|
| `background` | — |
| `surface` | `float` |
| `effect` | `ring` |
| `shader` | — |
| `rhythm` | `compact`, `regular` |
| `motion` | any value |
| `behavior` | `dismissible` |

## Anatomy

Authored (static) toast for tests / docs:

```html
<ol class="notifications" data-position="bottom-right"
    aria-live="polite" aria-label="Notifications">
  <li class="toast -success">
    <span class="toast__icon" aria-hidden="true">✓</span>
    <div class="toast__content">
      <strong class="toast__title">Saved</strong>
      <p class="toast__description">Your changes were saved.</p>
    </div>
    <button class="toast__dismiss" aria-label="Dismiss">×</button>
  </li>
</ol>
```

Programmatic (typical):

```js
notify.show({ title: "Sent", variant: "success" });
notify.show({ title: "Couldn't save", description: "Try again later.", variant: "danger" });
```

## Tokens consumed

- `--color-surface-float` — background
- `--color-info` / `--color-success` / `--color-warning` / `--color-danger` — variant accent
- `--color-ink-strong`, `--color-ink-regular`, `--color-ink-subtle`
- `--color-border-default` — border
- `--color-surface-canvas` — dismiss hover
- `--shadow-float` — elevation
- `--radius-card`, `--radius-button`
- `--space-inset-block-m`, `--space-gap-elements-s`
- `--size-control-s` — icon/dismiss size
- `--border-width-{hairline,emphasis,focus}`
- `--font-body-{s,m}-*`
- `--motion-fast`, `--motion-base`, `--motion-easing-default`

### Component-tier (defined inline on `.notifications`)
- `--notifications-gap` — gap between stacked toasts
- `--notifications-inset` — distance from viewport edge
- `--notifications-width` — toast width

## Accessibility contract

- The `<ol>` container carries `aria-live="polite"` so screen readers
  announce new toasts non-disruptively. For critical alerts, set
  `aria-live="assertive"` on the container (not on individual toasts).
- The container has `aria-label="Notifications"`.
- Each toast's dismiss button has `aria-label="Dismiss"`.
- Auto-dismiss should not be used for critical messages — users who
  rely on screen readers may not hear the announcement before the
  toast disappears. For critical content, use Dialog or
  `aria-live="assertive"` + `duration: 0` (persist).

## Guidance

### Do

- Keep titles short — they're announced verbatim.
- Use the appropriate variant so color + icon convey the message
  type at a glance.
- Persist (`duration: 0`) for messages the user must read.

### Don't

- Don't use Toast for blocking confirmations — use Dialog.
- Don't stack more than ~3 visible toasts at once. Consider grouping.
- Don't rely on auto-dismiss for important content — color and icon
  do not replace text.
\n ";