UI Components
Toast.
Small notifications that stack in a corner, close on their own and pause on hover. Trigger from script or a data attribute.
FeedbackComponentNo dependencies Original, MIT
Using Claude Code? Give it the agent prompt and skip these steps.
-
Import the foundation once
foundation.css holds the tokens every component reads.
--- // src/layouts/Layout.astro import "../styles/foundation.css"; --- -
Use it
--- import Toaster from "../components/viewership/toast/Toaster.astro"; --- <Toaster />
Props 4
| 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. |
Events
| Event | Detail | When |
|---|---|---|
vs-toast | CustomEvent<ToastOptions> | Dispatch on window to show a toast without importing anything. |
Source 2 files
---
/**
* 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> /**
* 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 }));
} Original, MIT.