Click a button to emit a toast. Hover an active toast to pause its auto-dismiss timer.
(Not connected to the runtime — just shows the visual.)
Your changes were saved.
Check your connection and try again.
/*
* 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.