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, shown without its comments; the files you install keep them, with the reasoning behind every rule.

src/components/ui/overlays/toast/Toast.astro
---
import type { HTMLAttributes } from "astro/types";

type Props = HTMLAttributes<"div"> & {
  variant?: "info" | "success" | "warning" | "error";
  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
---
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
---
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
---
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
---
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
---
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";

  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;
    toast.dispatchEvent(new Event("toast:dismiss", { bubbles: true }));
  }

  function toastsIn(region: HTMLElement): HTMLElement[] {
    return [...region.querySelectorAll<HTMLElement>('[data-slot="toast"]')];
  }

  function sync(region: HTMLElement) {
    for (const toast of toastsIn(region)) {
      const duration = Number(toast.dataset.duration);
      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) {
    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);

    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.