Skip to main content
astrocraft-ui/ components · 101

Toast

Free · MIT

Headless, 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

Install command
npx astrocraft-ui add overlays/toast

Plain-CSS theme — no build step

Install command
npx astrocraft-ui add overlays/toast --theme css

Tailwind theme — needs Tailwind v4

Install command
npx astrocraft-ui add overlays/toast --theme tailwind

Plain-CSS theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add overlays/toast --theme css --bridge lumos

Tailwind theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add overlays/toast --theme tailwind --bridge lumos

Live 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.

ComponentSlotsVariantsRuntime stateNative 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
---
// 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
---
// 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
---
// 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
---
// 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
---
// 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
---
// 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>
src/components/ui/overlays/toast/index.ts
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.