Toast
Free · MITHeadless, accessible, and styled entirely through the attribute surface below — bring your own CSS system, or start from the reference theme.
Install
Without a flag you get no styling at all — plain HTML carrying the attribute surface below. --theme copies the same theme in two dialects;--bridge lumos re-points either one onto Lumos for Astro's tokens (it needs a theme to re-point, and Lumos itself, already in your project).
Unstyled — behavior only, you write the CSS
npx astrocraft-ui add overlays/toastPlain-CSS theme — no build step
npx astrocraft-ui add overlays/toast --theme cssTailwind theme — needs Tailwind v4
npx astrocraft-ui add overlays/toast --theme tailwindPlain-CSS theme, re-pointed onto Lumos tokens
npx astrocraft-ui add overlays/toast --theme css --bridge lumosTailwind theme, re-pointed onto Lumos tokens
npx astrocraft-ui add overlays/toast --theme tailwind --bridge lumosLive demo
Toast
The Toaster is already in the page — that is the point of it. Showing a toast istoast.hidden = false, the library's ordinary visibility contract, and the region announces it because it existed first. Hover the stack and the auto-dismiss timers pause; the error toast has duration=0and waits for you.
Attribute surface
With no class-merging machinery, these attributes are the whole extension point. State is a data-attribute or a native attribute; visibility is the hiddenattribute. Generated from source by pnpm slots.
| Component | Slots | Variants | Runtime state | Native state |
|---|---|---|---|---|
| `Toast.astro` | `toast` | `data-variant`: `info` · `success` · `warning` · `error` | — | — |
| `ToastAction.astro` | `toast-action` | `data-variant`: `primary` · `secondary` · `outline` · `ghost` `data-size`: `sm` · `md` · `lg` | — | — |
| `ToastClose.astro` | `toast-close` | — | — | — |
| `ToastDescription.astro` | `toast-description` | — | — | — |
| `ToastTitle.astro` | `toast-title` | — | — | — |
| `Toaster.astro` | `toaster` | `data-politeness`: `polite` · `assertive` `data-position`: `top-right` · `top-left` · `bottom-right` · `bottom-left` · `top` · `bottom` | — | — |
Source
What the command copies — 7 files, alias-free so the copy lands standing alone. This is the whole component; there is nothing else.
---
// src/components/ui/overlays/toast/Toast.astro — Toast compound part (see ../../README.md).
// One notification. Render it inside a Toaster — outside one it is a styled box that announces
// nothing, because the live region is what speaks and the region has to pre-exist the message.
//
// Start it `hidden` and show it with `toast.hidden = false` (the library's visibility contract), or
// append it to the Toaster; either way the Toaster's observer arms the dismiss timer. It carries no
// role of its own: the Toaster is already the live region, and a `role="status"` here would make
// some screen readers read the same notification twice.
//
// `duration={0}` means it stays until someone closes it — the right choice for anything the user
// must act on, since a toast that dismisses itself is unreachable to anyone who reads slowly.
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"div"> & {
variant?: "info" | "success" | "warning" | "error";
/** Auto-dismiss after this many ms; `0` keeps it until closed. Paused while the stack is hovered. */
duration?: number;
};
const { variant = "info", duration = 5000, class: className, ...rest } = Astro.props;
---
<div class={className} data-slot="toast" data-variant={variant} data-duration={duration} {...rest}>
<slot />
</div>
---
// src/components/ui/overlays/toast/ToastAction.astro — Toast compound part (see ../../README.md).
// The "Undo" — the reason a toast is worth interrupting someone with at all. Attach your own click
// handler; the Toaster dismisses the toast afterwards, because an action that leaves its own
// notification on screen reads as though it did not take.
//
// Give it a label that stands alone. The toast may already have been announced and gone by the time
// a screen-reader user reaches this button, so "Undo" beats "Undo that".
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"button"> & {
variant?: "primary" | "secondary" | "outline" | "ghost";
size?: "sm" | "md" | "lg";
};
const { variant = "outline", size = "sm", class: className, ...rest } = Astro.props;
---
<button
type="button"
data-slot="toast-action"
data-variant={variant}
data-size={size}
class={className}
{...rest}
>
<slot />
</button>
---
// src/components/ui/overlays/toast/ToastClose.astro — Toast compound part (see ../../README.md).
// Dismisses its own toast; wired by the Toaster's delegated listener. It needs an accessible name —
// an icon-only close button with none is announced as just "button", and it is often the only way
// out of a toast whose `duration` is 0.
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"button"> & { label?: string };
const { label = "Dismiss", class: className, ...rest } = Astro.props;
---
<button type="button" aria-label={label} data-slot="toast-close" class={className} {...rest}>
<slot />
</button>
---
// src/components/ui/overlays/toast/ToastDescription.astro — Toast compound part (see ../../README.md).
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"p">;
const { class: className, ...rest } = Astro.props;
---
<p class={className} data-slot="toast-description" {...rest}><slot /></p>
---
// src/components/ui/overlays/toast/ToastTitle.astro — Toast compound part (see ../../README.md).
// The one line that has to survive being read aloud on its own.
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"p">;
const { class: className, ...rest } = Astro.props;
---
<p class={className} data-slot="toast-title" {...rest}><slot /></p>
---
// src/components/ui/overlays/toast/Toaster.astro — headless primitive (see ../../README.md).
// The live region every Toast is announced through, and the controller for all of them. Render it
// ONCE, high in your layout; put Toasts inside it, server-rendered or appended later.
//
// THE ORDER IS THE WHOLE POINT. `aria-live` is only honoured on a region that was already in the
// document when its contents changed: create the region and the message in the same tick and screen
// readers announce nothing at all, silently, on every platform. That single fact is why a toast
// stack is a component and not a `<div>` — everything else about it is a list.
//
// This element is also the controller, because toasts arrive after load: a MutationObserver watches
// for a Toast being appended or un-hidden and arms its dismiss timer then, so a consumer's own code
// never has to call into the library. Showing a toast from anywhere is
// `toast.hidden = false` — the library's ordinary visibility contract — or appending one.
//
// `politeness` is per-region because that is how the platform works: a region cannot be polite for
// one message and assertive for the next. Render a second Toaster with `politeness="assertive"` if
// you need to interrupt, and send errors to that one.
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"div"> & {
label?: string;
politeness?: "polite" | "assertive";
position?: "top-right" | "top-left" | "bottom-right" | "bottom-left" | "top" | "bottom";
};
const {
label = "Notifications",
politeness = "polite",
position = "bottom-right",
class: className,
...rest
} = Astro.props;
---
<div
role="region"
aria-label={label}
aria-live={politeness}
class={className}
data-slot="toaster"
data-position={position}
{...rest}
>
<slot />
</div>
<script>
import { onReadyOnce } from "../../_once";
// The duration each pending timer was armed WITH, not just its handle: `sync` runs on every
// mutation, so it needs to tell "already counting down correctly" from "counting down to the wrong
// number" — otherwise either every mutation restarts the clock, or a duration changed after the
// toast appeared is watched and then quietly ignored.
const timers = new WeakMap<
HTMLElement,
{ id: ReturnType<typeof setTimeout>; duration: number }
>();
const paused = new WeakSet<HTMLElement>();
function clear(toast: HTMLElement) {
clearTimeout(timers.get(toast)?.id);
timers.delete(toast);
}
function dismiss(toast: HTMLElement) {
clear(toast);
toast.hidden = true;
// So a consumer can drop it from their own state, or re-show it later.
toast.dispatchEvent(new Event("toast:dismiss", { bubbles: true }));
}
function toastsIn(region: HTMLElement): HTMLElement[] {
return [...region.querySelectorAll<HTMLElement>('[data-slot="toast"]')];
}
/**
* Arm or disarm every toast in the region to match what is on screen. Idempotent, because the
* observer below calls it for every mutation — including the `hidden` write that dismissing does.
*/
function sync(region: HTMLElement) {
for (const toast of toastsIn(region)) {
const duration = Number(toast.dataset.duration);
// `!duration` catches both 0 (stay until dismissed) and a missing or unparseable attribute.
if (toast.hidden || paused.has(region) || !duration) {
clear(toast);
} else if (timers.get(toast)?.duration !== duration) {
clear(toast);
timers.set(toast, { id: setTimeout(() => dismiss(toast), duration), duration });
}
}
}
function wire(region: HTMLElement) {
// Pointer or keyboard anywhere in the stack pauses ALL of it: the timers exist to clear toasts
// nobody is looking at, and someone reaching for the "Undo" on the third one is looking at all
// three. Resuming restarts the full duration rather than the remainder — the simpler arithmetic,
// and the one that errs towards giving a reader more time rather than less.
const pause = () => {
paused.add(region);
sync(region);
};
const resume = () => {
paused.delete(region);
sync(region);
};
region.addEventListener("pointerenter", pause);
region.addEventListener("pointerleave", resume);
region.addEventListener("focusin", pause);
region.addEventListener("focusout", resume);
// Acting on a toast dismisses it — an "Undo" that leaves its own toast on screen reads as if it
// did not work. The consumer's own handler has already run by the time this delegated one does.
region.addEventListener("click", (event) => {
if (!(event.target instanceof Element)) return;
const control = event.target.closest('[data-slot="toast-close"], [data-slot="toast-action"]');
const toast = control?.closest<HTMLElement>('[data-slot="toast"]');
if (toast) dismiss(toast);
});
new MutationObserver(() => sync(region)).observe(region, {
childList: true,
subtree: true,
attributeFilter: ["hidden", "data-duration"],
});
sync(region);
}
onReadyOnce('[data-slot="toaster"]', wire);
</script>
import Toast from "./Toast.astro";
import ToastAction from "./ToastAction.astro";
import ToastClose from "./ToastClose.astro";
import ToastDescription from "./ToastDescription.astro";
import Toaster from "./Toaster.astro";
import ToastTitle from "./ToastTitle.astro";
export { Toast, ToastAction, ToastClose, ToastDescription, Toaster, ToastTitle };
export default Toaster;
What you get
The component source, copied into your project by npx astrocraft-ui add overlays/toast — no package dependency, no CSS to fight. Whatever the paid blocks compose, this is it.