shshadcn-htmx

Components

Toast

Transient notifications that appear in a fixed-position viewport. The htmx-native pattern: render the viewport once, return one toast fragment per request, htmx appends, the boot script auto-dismisses after a timeout.

Installation

1. Install via the shadcn CLI

npx shadcn@latest add http://localhost/r/toast.json

2. Use it

components/ui/toast.tsx
// Render the viewport ONCE in your layout:
import { ToastViewport, Toast, ToastTitle, ToastDescription } from "@/components/ui/toast"

<ToastViewport position="bottom-right" />

// From an htmx endpoint, return a single Toast:
//   hx-target="#toast-viewport"  hx-swap="beforeend"
<Toast variant="success">
  <ToastTitle>Saved</ToastTitle>
  <ToastDescription>Your changes have been recorded.</ToastDescription>
</Toast>
Or copy the source manually
components/ui/toast.tsx
/** @jsxImportSource hono/jsx */
import type { PropsWithChildren } from "hono/jsx"
import { cn, type ClassValue } from "@/registry/lib/cn"

// Toast — shadcn-htmx, htmx v4 + Tailwind v4.
//
// shadcn upstream uses sonner (third-party). We don't. The htmx-native
// pattern is:
//
//   1. Render <ToastViewport id="toast-viewport" /> ONCE in your layout.
//   2. From any htmx trigger, post to an endpoint that returns a <Toast>
//      fragment with hx-target="#toast-viewport" hx-swap="beforeend".
//   3. site.js auto-dismisses the toast after data-duration ms; the user
//      can also click the close button.
//
// Accessibility:
//   - The viewport is role="region" with aria-label so AT users can find
//     and tab into the "Notifications" landmark.
//   - Each toast carries its own role/aria-live based on `live` (polite
//     by default, assertive for urgent).
//   - Focus is NOT moved to the toast (it would interrupt the user's
//     work). Instead, the live region announces the message inline.
//
// Refs:
//   repos/mdn/files/en-us/web/accessibility/aria/reference/attributes/aria-live/
//   repos/aria-practices/content/patterns/alert/ (informs the alert role)
//   repos/mdn/files/en-us/web/accessibility/aria/reference/roles/status_role/

export type ToastViewportPosition =
  | "top-right"
  | "top-left"
  | "top-center"
  | "bottom-right"
  | "bottom-left"
  | "bottom-center"

const VIEWPORT_POSITION: Record<ToastViewportPosition, string> = {
  "top-right": "top-4 right-4 flex-col items-end",
  "top-left": "top-4 left-4 flex-col items-start",
  "top-center": "top-4 left-1/2 -translate-x-1/2 flex-col items-center",
  "bottom-right": "bottom-4 right-4 flex-col-reverse items-end",
  "bottom-left": "bottom-4 left-4 flex-col-reverse items-start",
  "bottom-center": "bottom-4 left-1/2 -translate-x-1/2 flex-col-reverse items-center",
}

type ToastViewportProps = PropsWithChildren<{
  id?: string
  position?: ToastViewportPosition
  ariaLabel?: string
  // Politeness of the viewport's live region. Because the viewport is
  // rendered once and primed (empty) at page load, toasts swapped in later
  // are announced as *additions* to an already-existing live region —
  // which is what actually gets polite (role=status) toasts read out.
  // See repos/mdn/.../aria/guides/live_regions/ ("Start with an empty live
  // region, then — in a separate step — change the content").
  live?: ToastLive
  class?: ClassValue
}>

export function ToastViewport(props: ToastViewportProps) {
  const {
    id = "toast-viewport",
    position = "bottom-right",
    ariaLabel = "Notifications",
    live = "polite",
    class: className,
    children,
  } = props
  return (
    <ol
      id={id}
      role="region"
      aria-label={ariaLabel}
      // Primed live region: aria-live makes additions announce; aria-atomic
      // false so only the newly-added toast is read, not every existing one.
      // aria-relevant defaults to "additions text" (per MDN aria-relevant).
      aria-live={live}
      aria-atomic="false"
      data-slot="toast-viewport"
      data-position={position}
      class={cn(
        "pointer-events-none fixed z-50 flex w-full max-w-[420px] gap-2 p-2",
        VIEWPORT_POSITION[position],
        className,
      )}
    >
      {children}
    </ol>
  )
}

export type ToastVariant = "default" | "destructive" | "success" | "warning" | "info"
export type ToastLive = "polite" | "assertive"

const toastBase =
  "pointer-events-auto relative grid w-full grid-cols-[0_1fr_auto] items-start gap-y-0.5 rounded-lg border bg-card px-4 py-3 text-sm text-card-foreground shadow-lg " +
  "has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr_auto] has-[>svg]:gap-x-3 " +
  "[&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current " +
  // Entrance animation via Tailwind v4 keyframes. The viewport position
  // determines the slide direction (see CSS keyframes in input.css).
  "animate-[scn-toast-in_180ms_ease-out] " +
  "data-[state=closed]:animate-[scn-toast-out_140ms_ease-in]"

const toastVariants: Record<ToastVariant, string> = {
  default: "",
  destructive:
    "border-destructive/30 bg-destructive/5 text-destructive *:data-[slot=toast-description]:text-destructive/90",
  success:
    "border-emerald-500/30 bg-emerald-500/5 text-emerald-700 dark:text-emerald-300 *:data-[slot=toast-description]:text-emerald-700/90 dark:*:data-[slot=toast-description]:text-emerald-300/90",
  warning:
    "border-amber-500/30 bg-amber-500/10 text-amber-800 dark:text-amber-200 *:data-[slot=toast-description]:text-amber-800/90 dark:*:data-[slot=toast-description]:text-amber-200/90",
  info:
    "border-sky-500/30 bg-sky-500/5 text-sky-800 dark:text-sky-200 *:data-[slot=toast-description]:text-sky-800/90 dark:*:data-[slot=toast-description]:text-sky-200/90",
}

// htmx attribute surface spread onto the close button so dismiss can also
// fire a server request (e.g. mark-notification-read / analytics) while
// site.js still handles the DOM removal + exit animation. Mirrors the
// allowlist on Button (see registry/ui/button.tsx). Progressive
// enhancement: the button works without these too.
// See repos/htmx/www/src/content/reference/01-attributes/.
export type ToastCloseHx = {
  "hx-get"?: string
  "hx-post"?: string
  "hx-put"?: string
  "hx-patch"?: string
  "hx-delete"?: string
  "hx-target"?: string
  "hx-swap"?: string
  "hx-trigger"?: string
  "hx-indicator"?: string
  "hx-confirm"?: string
  "hx-vals"?: string
}

type ToastProps = PropsWithChildren<{
  variant?: ToastVariant
  // Auto-dismiss timeout in ms. Set 0 to keep the toast until the user
  // closes it manually (useful for important confirmations).
  duration?: number
  // Live-region politeness. "polite" (default) for normal notifications;
  // "assertive" for urgent (errors after submit, lost connection).
  live?: ToastLive
  // Show the X close button (default true).
  showClose?: boolean
  // Optional htmx attributes forwarded onto the close button so dismissing
  // can notify the server (mark-read, etc.) in addition to local removal.
  closeHx?: ToastCloseHx
  id?: string
  class?: ClassValue
}>

export function Toast(props: ToastProps) {
  const {
    children,
    variant = "default",
    duration = 5000,
    live = "polite",
    showClose = true,
    closeHx,
    id,
    class: className,
  } = props
  const role = live === "assertive" ? "alert" : "status"
  return (
    <li
      id={id}
      data-slot="toast"
      data-variant={variant}
      data-state="open"
      data-duration={duration}
      role={role}
      aria-live={live}
      aria-atomic="true"
      class={cn(toastBase, toastVariants[variant], className)}
    >
      {children}
      {showClose && (
        <button
          type="button"
          data-toast-close="true"
          aria-label="Dismiss notification"
          {...closeHx}
          class="col-start-3 row-span-2 row-start-1 inline-flex size-6 -translate-y-0.5 items-center justify-center self-start rounded-md text-current opacity-60 transition-opacity hover:bg-current/10 hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none"
        >
          <svg
            xmlns="http://www.w3.org/2000/svg"
            viewBox="0 0 24 24"
            fill="none"
            stroke="currentColor"
            stroke-width="2"
            stroke-linecap="round"
            stroke-linejoin="round"
            class="size-3.5"
            aria-hidden="true"
          >
            <path d="M18 6 6 18" />
            <path d="m6 6 12 12" />
          </svg>
        </button>
      )}
    </li>
  )
}

export function ToastTitle(props: PropsWithChildren<{ class?: ClassValue }>) {
  return (
    <div
      data-slot="toast-title"
      class={cn("col-start-2 line-clamp-1 font-medium tracking-tight", props.class)}
    >
      {props.children}
    </div>
  )
}

export function ToastDescription(
  props: PropsWithChildren<{ class?: ClassValue }>,
) {
  return (
    <div
      data-slot="toast-description"
      class={cn("col-start-2 text-sm text-muted-foreground", props.class)}
    >
      {props.children}
    </div>
  )
}

1. Save the file

Copy toast.html into templates/components/.

2. Use it

templates/components/toast.html
{% from "components/toast.html" import toast_viewport, toast %}

{# In your base layout, once: #}
{{ toast_viewport() }}

{# From an htmx endpoint (hx-target="#toast-viewport" hx-swap="beforeend"): #}
{{ toast(title="Saved", description="Your changes have been recorded.",
         variant="success") }}
View source
templates/components/toast.html
{# Toast macros — shadcn-htmx, htmx v4 + Tailwind v4.
   Mirrors registry/ui/toast.tsx. Pattern:
     1. Render {{ toast_viewport() }} once in your layout (a fixed-position
        <ol> with role="region" + aria-label).
     2. From any htmx-triggered endpoint, render a single toast and target
        the viewport with hx-target="#toast-viewport" hx-swap="beforeend".
     3. site.js auto-dismisses each toast after data-duration ms and wires
        up the X close button. #}

{% macro toast_viewport(
    id="toast-viewport",
    position="bottom-right",
    aria_label="Notifications",
    live="polite",
    extra_class=""
) %}
{%- set positions = {
    "top-right":     "top-4 right-4 flex-col items-end",
    "top-left":      "top-4 left-4 flex-col items-start",
    "top-center":    "top-4 left-1/2 -translate-x-1/2 flex-col items-center",
    "bottom-right":  "bottom-4 right-4 flex-col-reverse items-end",
    "bottom-left":   "bottom-4 left-4 flex-col-reverse items-start",
    "bottom-center": "bottom-4 left-1/2 -translate-x-1/2 flex-col-reverse items-center"
} -%}
{# Primed live region: aria-live makes toasts swapped in announce as
   *additions* to an already-existing region (polite/role=status alone on a
   freshly-injected node is generally NOT read). aria-atomic=false so only
   the newly-added toast is announced. aria-relevant defaults to
   "additions text". See repos/mdn/.../aria/guides/live_regions/. #}
<ol id="{{ id }}" role="region" aria-label="{{ aria_label }}"
    aria-live="{{ live }}" aria-atomic="false"
    data-slot="toast-viewport" data-position="{{ position }}"
    class="pointer-events-none fixed z-50 flex w-full max-w-[420px] gap-2 p-2 {{ positions[position] }} {{ extra_class }}"></ol>
{% endmacro %}

{% macro toast(
    title,
    description=none,
    variant="default",
    duration=5000,
    live="polite",
    show_close=true,
    close_hx={},
    id=none,
    extra_class=""
) %}
{%- set base -%}
pointer-events-auto relative grid w-full grid-cols-[0_1fr_auto] items-start gap-y-0.5 rounded-lg border bg-card px-4 py-3 text-sm text-card-foreground shadow-lg has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr_auto] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current animate-[scn-toast-in_180ms_ease-out] data-[state=closed]:animate-[scn-toast-out_140ms_ease-in]
{%- endset -%}
{%- set variants = {
    "default": "",
    "destructive": "border-destructive/30 bg-destructive/5 text-destructive *:data-[slot=toast-description]:text-destructive/90",
    "success": "border-emerald-500/30 bg-emerald-500/5 text-emerald-700 dark:text-emerald-300 *:data-[slot=toast-description]:text-emerald-700/90 dark:*:data-[slot=toast-description]:text-emerald-300/90",
    "warning": "border-amber-500/30 bg-amber-500/10 text-amber-800 dark:text-amber-200 *:data-[slot=toast-description]:text-amber-800/90 dark:*:data-[slot=toast-description]:text-amber-200/90",
    "info": "border-sky-500/30 bg-sky-500/5 text-sky-800 dark:text-sky-200 *:data-[slot=toast-description]:text-sky-800/90 dark:*:data-[slot=toast-description]:text-sky-200/90"
} -%}
{%- set role = "alert" if live == "assertive" else "status" -%}
<li {% if id %}id="{{ id }}"{% endif %}
    data-slot="toast" data-variant="{{ variant }}" data-state="open" data-duration="{{ duration }}"
    role="{{ role }}" aria-live="{{ live }}" aria-atomic="true"
    class="{{ base }} {{ variants[variant] }} {{ extra_class }}">
  <div data-slot="toast-title" class="col-start-2 line-clamp-1 font-medium tracking-tight">{{ title }}</div>
  {% if description %}<div data-slot="toast-description" class="col-start-2 text-sm text-muted-foreground">{{ description }}</div>{% endif %}
  {% if show_close %}
  {# close_hx: optional htmx attrs (e.g. {"hx-post": "/notifications/123/read"})
     forwarded onto the close button so dismiss can notify the server while
     site.js still handles DOM removal. See repos/htmx/.../01-attributes/. #}
  <button type="button" data-toast-close="true" aria-label="Dismiss notification"
    {%- for name, value in close_hx.items() %} {{ name }}="{{ value }}"{% endfor %}
    class="col-start-3 row-span-2 row-start-1 inline-flex size-6 -translate-y-0.5 items-center justify-center self-start rounded-md text-current opacity-60 transition-opacity hover:bg-current/10 hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none">
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-3.5" aria-hidden="true">
      <path d="M18 6 6 18" /><path d="m6 6 12 12" />
    </svg>
  </button>
  {% endif %}
</li>
{% endmacro %}

1. Save the file

Add toast.tmpl alongside button.tmpl.

2. Use it

templates/components/toast.tmpl
{{/* In your base layout: */}}
{{template "toast_viewport" (dict)}}

{{/* From an htmx endpoint: */}}
{{template "toast" (dict
  "Title" "Saved"
  "Description" "Your changes have been recorded."
  "Variant" "success"
  "ShowClose" true
)}}
View source
templates/components/toast.tmpl
{{/*
  Toast templates — shadcn-htmx, htmx v4 + Tailwind v4.
  Mirrors registry/ui/toast.tsx.

  Render {{template "toast_viewport"}} once in your layout. Endpoints that
  flash a notification return a {{template "toast" ...}} fragment and target
  the viewport with hx-target="#toast-viewport" hx-swap="beforeend".

      type ToastArgs struct {
          Title, Description, Variant, Live string
          Duration                          int
          ShowClose                         bool
          // CloseHx: optional htmx attrs spread onto the close button so
          // dismiss can also notify the server, e.g.
          //   map[string]string{"hx-post": "/notifications/123/read"}
          CloseHx map[string]string
          ID      string
      }

      type ToastViewportArgs struct {
          ID, Position, AriaLabel string
          // Live: viewport live-region politeness ("polite"|"assertive").
          Live string
      }
*/}}

{{define "toast_viewport"}}
{{- $id := or .ID "toast-viewport" -}}
{{- $position := or .Position "bottom-right" -}}
{{- $live := or .Live "polite" -}}
{{- $positions := dict
    "top-right"     "top-4 right-4 flex-col items-end"
    "top-left"      "top-4 left-4 flex-col items-start"
    "top-center"    "top-4 left-1/2 -translate-x-1/2 flex-col items-center"
    "bottom-right"  "bottom-4 right-4 flex-col-reverse items-end"
    "bottom-left"   "bottom-4 left-4 flex-col-reverse items-start"
    "bottom-center" "bottom-4 left-1/2 -translate-x-1/2 flex-col-reverse items-center" -}}
{{/* Primed live region: aria-live on the persistent viewport makes toasts
     swapped in announce as additions; aria-atomic=false so only the new
     toast is read. polite/role=status on a freshly-injected node alone is
     generally NOT announced. See repos/mdn/.../aria/guides/live_regions/. */}}
<ol id="{{$id}}" role="region" aria-label="{{or .AriaLabel "Notifications"}}"
    aria-live="{{$live}}" aria-atomic="false"
    data-slot="toast-viewport" data-position="{{$position}}"
    class="pointer-events-none fixed z-50 flex w-full max-w-[420px] gap-2 p-2 {{index $positions $position}}"></ol>
{{end}}

{{define "toast"}}
{{- $variant := or .Variant "default" -}}
{{- $live := or .Live "polite" -}}
{{- $duration := or .Duration 5000 -}}
{{- $base := "pointer-events-auto relative grid w-full grid-cols-[0_1fr_auto] items-start gap-y-0.5 rounded-lg border bg-card px-4 py-3 text-sm text-card-foreground shadow-lg has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr_auto] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current animate-[scn-toast-in_180ms_ease-out] data-[state=closed]:animate-[scn-toast-out_140ms_ease-in]" -}}
{{- $variants := dict
    "default" ""
    "destructive" "border-destructive/30 bg-destructive/5 text-destructive *:data-[slot=toast-description]:text-destructive/90"
    "success" "border-emerald-500/30 bg-emerald-500/5 text-emerald-700 dark:text-emerald-300 *:data-[slot=toast-description]:text-emerald-700/90 dark:*:data-[slot=toast-description]:text-emerald-300/90"
    "warning" "border-amber-500/30 bg-amber-500/10 text-amber-800 dark:text-amber-200 *:data-[slot=toast-description]:text-amber-800/90 dark:*:data-[slot=toast-description]:text-amber-200/90"
    "info" "border-sky-500/30 bg-sky-500/5 text-sky-800 dark:text-sky-200 *:data-[slot=toast-description]:text-sky-800/90 dark:*:data-[slot=toast-description]:text-sky-200/90" -}}
{{- $role := "status" -}}{{- if eq $live "assertive" -}}{{- $role = "alert" -}}{{- end -}}
<li {{if .ID}}id="{{.ID}}"{{end}}
    data-slot="toast" data-variant="{{$variant}}" data-state="open" data-duration="{{$duration}}"
    role="{{$role}}" aria-live="{{$live}}" aria-atomic="true"
    class="{{$base}} {{index $variants $variant}}">
  <div data-slot="toast-title" class="col-start-2 line-clamp-1 font-medium tracking-tight">{{.Title}}</div>
  {{if .Description}}<div data-slot="toast-description" class="col-start-2 text-sm text-muted-foreground">{{.Description}}</div>{{end}}
  {{if .ShowClose}}
  {{/* CloseHx: optional htmx attrs forwarded onto the close button so dismiss
       can notify the server while site.js still removes the node.
       See repos/htmx/www/src/content/reference/01-attributes/. */}}
  <button type="button" data-toast-close="true" aria-label="Dismiss notification"
    {{- range $name, $value := .CloseHx}} {{$name}}="{{$value}}"{{end}}
    class="col-start-3 row-span-2 row-start-1 inline-flex size-6 -translate-y-0.5 items-center justify-center self-start rounded-md text-current opacity-60 transition-opacity hover:bg-current/10 hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none">
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-3.5" aria-hidden="true">
      <path d="M18 6 6 18"/><path d="m6 6 12 12"/>
    </svg>
  </button>
  {{end}}
</li>
{{end}}

1. Save the file

Drop toast.ex into lib/my_app_web/components/.

2. Use it

lib/my_app_web/components/toast.ex
# In your root layout:
<.toast_viewport />

# From an endpoint (hx-target="#toast-viewport" hx-swap="beforeend"):
<.toast title="Saved" description="Your changes have been recorded."
        variant="success" />
View source
lib/my_app_web/components/toast.ex
defmodule ShadcnHtmx.Components.Toast do
  @moduledoc """
  Toast — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.

  Pattern:
    1. Render `<.toast_viewport />` once in your layout.
    2. Endpoints that flash return a `<.toast>` fragment with
       hx-target="#toast-viewport" hx-swap="beforeend".
    3. The boot script in public/site.js auto-dismisses each toast after
       data-duration ms and wires up the X close button.

  ## Examples

      <.toast_viewport />

      # From an endpoint:
      <.toast title="Saved" description="Your changes were recorded." />
      <.toast variant="destructive" live="assertive"
              title="Save failed" description="Try again." />
  """

  use Phoenix.Component

  @viewport_positions %{
    "top-right" => "top-4 right-4 flex-col items-end",
    "top-left" => "top-4 left-4 flex-col items-start",
    "top-center" => "top-4 left-1/2 -translate-x-1/2 flex-col items-center",
    "bottom-right" => "bottom-4 right-4 flex-col-reverse items-end",
    "bottom-left" => "bottom-4 left-4 flex-col-reverse items-start",
    "bottom-center" =>
      "bottom-4 left-1/2 -translate-x-1/2 flex-col-reverse items-center"
  }

  attr :id, :string, default: "toast-viewport"

  attr :position, :string,
    default: "bottom-right",
    values: ~w(top-right top-left top-center bottom-right bottom-left bottom-center)

  attr :aria_label, :string, default: "Notifications"

  # Politeness of the viewport's primed live region. Because the viewport is
  # rendered once (empty) at page load, toasts swapped in later announce as
  # *additions* to an existing live region — which is what gets polite
  # (role=status) toasts read out at all.
  # See repos/mdn/.../aria/guides/live_regions/.
  attr :live, :string, default: "polite", values: ~w(polite assertive)

  attr :class, :string, default: nil

  def toast_viewport(assigns) do
    assigns = assign(assigns, :position_class, Map.fetch!(@viewport_positions, assigns.position))

    ~H"""
    <ol
      id={@id}
      role="region"
      aria-label={@aria_label}
      aria-live={@live}
      aria-atomic="false"
      data-slot="toast-viewport"
      data-position={@position}
      class={[
        "pointer-events-none fixed z-50 flex w-full max-w-[420px] gap-2 p-2",
        @position_class,
        @class
      ]}
    >
    </ol>
    """
  end

  @base "pointer-events-auto relative grid w-full grid-cols-[0_1fr_auto] items-start gap-y-0.5 rounded-lg border bg-card px-4 py-3 text-sm text-card-foreground shadow-lg " <>
          "has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr_auto] has-[>svg]:gap-x-3 " <>
          "[&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current " <>
          "animate-[scn-toast-in_180ms_ease-out] data-[state=closed]:animate-[scn-toast-out_140ms_ease-in]"

  @variants %{
    "default" => "",
    "destructive" =>
      "border-destructive/30 bg-destructive/5 text-destructive *:data-[slot=toast-description]:text-destructive/90",
    "success" =>
      "border-emerald-500/30 bg-emerald-500/5 text-emerald-700 dark:text-emerald-300 *:data-[slot=toast-description]:text-emerald-700/90 dark:*:data-[slot=toast-description]:text-emerald-300/90",
    "warning" =>
      "border-amber-500/30 bg-amber-500/10 text-amber-800 dark:text-amber-200 *:data-[slot=toast-description]:text-amber-800/90 dark:*:data-[slot=toast-description]:text-amber-200/90",
    "info" =>
      "border-sky-500/30 bg-sky-500/5 text-sky-800 dark:text-sky-200 *:data-[slot=toast-description]:text-sky-800/90 dark:*:data-[slot=toast-description]:text-sky-200/90"
  }

  attr :title, :string, required: true
  attr :description, :string, default: nil
  attr :variant, :string, default: "default", values: ~w(default destructive success warning info)
  attr :duration, :integer, default: 5000
  attr :live, :string, default: "polite", values: ~w(polite assertive)
  attr :show_close, :boolean, default: true

  # Optional htmx attrs forwarded onto the close button so dismiss can notify
  # the server (mark-read, etc.) while site.js still removes the node, e.g.
  # close_hx={%{"hx-post" => "/notifications/123/read"}}.
  # See repos/htmx/www/src/content/reference/01-attributes/.
  attr :close_hx, :map, default: %{}

  attr :class, :string, default: nil
  attr :id, :string, default: nil

  def toast(assigns) do
    role = if assigns.live == "assertive", do: "alert", else: "status"

    assigns =
      assigns
      |> assign(:base, @base)
      |> assign(:variant_class, Map.fetch!(@variants, assigns.variant))
      |> assign(:role, role)

    ~H"""
    <li
      id={@id}
      data-slot="toast"
      data-variant={@variant}
      data-state="open"
      data-duration={@duration}
      role={@role}
      aria-live={@live}
      aria-atomic="true"
      class={[@base, @variant_class, @class]}
    >
      <div data-slot="toast-title" class="col-start-2 line-clamp-1 font-medium tracking-tight">
        {@title}
      </div>
      <div :if={@description} data-slot="toast-description" class="col-start-2 text-sm text-muted-foreground">
        {@description}
      </div>
      <button
        :if={@show_close}
        type="button"
        data-toast-close="true"
        aria-label="Dismiss notification"
        {@close_hx}
        class="col-start-3 row-span-2 row-start-1 inline-flex size-6 -translate-y-0.5 items-center justify-center self-start rounded-md text-current opacity-60 transition-opacity hover:bg-current/10 hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none"
      >
        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-3.5" aria-hidden="true">
          <path d="M18 6 6 18" /><path d="m6 6 12 12" />
        </svg>
      </button>
    </li>
    """
  end
end

1. Save the file

Includes the inline auto-dismiss script — copy once per page.

2. Use it

index.html
<!-- Once in layout -->
<ol id="toast-viewport" role="region" aria-label="Notifications"></ol>

<!-- Server-returned fragment (appended to viewport) -->
<li data-slot="toast" data-variant="success" data-state="open" data-duration="5000"
    role="status" aria-live="polite" aria-atomic="true" class="…">
  …title, description, close button…
</li>
View source
index.html
<!--
  shadcn-htmx — raw HTML toast snippets.

  Pattern:
    1. Render the viewport once in your layout. It's a role="region" so
       AT users can locate "Notifications" in their landmark list.
    2. Endpoints that flash messages return a single <li data-slot="toast">
       fragment with hx-target="#toast-viewport" hx-swap="beforeend".
    3. Include the JS at the bottom of this file to auto-dismiss after
       data-duration ms and wire up the close button.

  VIEWPORT (render once):
-->
<!--
  The viewport is also the live region: aria-live (default "polite", use
  "assertive" for urgent streams) + aria-atomic="false" so each toast
  swapped in is announced as an *addition* to this already-existing,
  primed region. A polite (role="status") node injected on its own is
  generally NOT announced — the region must exist first.
  See repos/mdn/.../aria/guides/live_regions/.
-->
<ol id="toast-viewport"
    role="region" aria-label="Notifications"
    aria-live="polite" aria-atomic="false"
    data-slot="toast-viewport" data-position="bottom-right"
    class="pointer-events-none fixed bottom-4 right-4 z-50 flex w-full max-w-[420px] flex-col-reverse items-end gap-2 p-2">
</ol>

<!-- TOAST (server-returned fragment) — polite success -->
<li data-slot="toast" data-variant="success" data-state="open" data-duration="5000"
    role="status" aria-live="polite" aria-atomic="true"
    class="pointer-events-auto relative grid w-full grid-cols-[0_1fr_auto] items-start gap-y-0.5 rounded-lg border border-emerald-500/30 bg-emerald-500/5 px-4 py-3 text-sm text-emerald-700 shadow-lg dark:text-emerald-300 has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr_auto] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current animate-[scn-toast-in_180ms_ease-out] data-[state=closed]:animate-[scn-toast-out_140ms_ease-in]">
  <div data-slot="toast-title" class="col-start-2 line-clamp-1 font-medium tracking-tight">Saved</div>
  <div data-slot="toast-description" class="col-start-2 text-sm text-emerald-700/90 dark:text-emerald-300/90">
    Your changes have been recorded.
  </div>
  <!--
    Optional: add htmx attrs to the close button so dismiss also notifies
    the server (mark-read, analytics) while the JS below still removes the
    node, e.g.  hx-post="/notifications/123/read" hx-swap="none"
    See repos/htmx/www/src/content/reference/01-attributes/.
  -->
  <button type="button" data-toast-close="true" aria-label="Dismiss notification"
    class="col-start-3 row-span-2 row-start-1 inline-flex size-6 -translate-y-0.5 items-center justify-center self-start rounded-md opacity-60 hover:bg-current/10 hover:opacity-100">
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-3.5" aria-hidden="true">
      <path d="M18 6 6 18"/><path d="m6 6 12 12"/>
    </svg>
  </button>
</li>

<!-- TOAST — assertive destructive -->
<li data-slot="toast" data-variant="destructive" data-state="open" data-duration="0"
    role="alert" aria-live="assertive" aria-atomic="true"
    class="pointer-events-auto relative grid w-full grid-cols-[0_1fr_auto] items-start gap-y-0.5 rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive shadow-lg has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr_auto] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current animate-[scn-toast-in_180ms_ease-out] data-[state=closed]:animate-[scn-toast-out_140ms_ease-in]">
  <div data-slot="toast-title" class="col-start-2 line-clamp-1 font-medium tracking-tight">Save failed</div>
  <div data-slot="toast-description" class="col-start-2 text-sm text-destructive/90">
    Couldn't reach the server. Try again in a moment.
  </div>
  <button type="button" data-toast-close="true" aria-label="Dismiss notification"
    class="col-start-3 row-span-2 row-start-1 inline-flex size-6 -translate-y-0.5 items-center justify-center self-start rounded-md opacity-60 hover:bg-current/10 hover:opacity-100">
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-3.5" aria-hidden="true">
      <path d="M18 6 6 18"/><path d="m6 6 12 12"/>
    </svg>
  </button>
</li>

<!-- KEYFRAMES + JS — copy once at page bottom -->
<style>
  @keyframes scn-toast-in  { from { opacity: 0; transform: translateX(24px); } to { opacity: 1; transform: translateX(0); } }
  @keyframes scn-toast-out { from { opacity: 1; transform: translateX(0); } to { opacity: 0; transform: translateX(24px); } }
</style>
<script>
  (function () {
    var arm = function (toast) {
      if (toast._scnArmed) return; toast._scnArmed = true
      var d = Number(toast.getAttribute('data-duration') || 0)
      var dismiss = function () {
        if (toast.getAttribute('data-state') === 'closed') return
        toast.setAttribute('data-state', 'closed')
        setTimeout(function () { toast.remove() }, 160)
      }
      var c = toast.querySelector('[data-toast-close]'); if (c) c.addEventListener('click', dismiss)
      if (d > 0) setTimeout(dismiss, d)
    }
    document.querySelectorAll('[data-slot="toast"]').forEach(arm)
    document.querySelectorAll('[data-slot="toast-viewport"]').forEach(function (vp) {
      new MutationObserver(function (rs) { rs.forEach(function (r) { r.addedNodes.forEach(function (n) {
        if (n.matches && n.matches('[data-slot="toast"]')) arm(n)
        if (n.querySelectorAll) n.querySelectorAll('[data-slot="toast"]').forEach(arm)
      }) }) }).observe(vp, { childList: true })
    })
  })()
</script>

Examples

Trigger via htmx — server-driven flash

Click a button — htmx posts, the server returns a Toast fragment, htmx appends it to the viewport, the boot script auto-dismisses after 5 s.

This is the htmx flash pattern. No client-side queue management, no observer; the viewport is just a list and htmx appends to it. hx-swap="beforeend" is the secret sauce — items stack at the end of the list, and the viewport's flex-col-reverse visual order means new toasts appear on top.

Toasts appear in the bottom-right of this docs page.

<Button hx-post="/api/save" hx-target="#toast-viewport" hx-swap="beforeend">
  Save
</Button>

// Server endpoint returns:
<Toast variant="success">
  <ToastTitle>Saved</ToastTitle>
  <ToastDescription>Your changes are stored.</ToastDescription>
</Toast>
{{ button("Save",
            hx_post="/api/save",
            hx_target="#toast-viewport",
            hx_swap="beforeend") }}

{# Server endpoint returns: #}
{{ toast(title="Saved", description="Your changes are stored.", variant="success") }}
{{template "button" (dict "Label" "Save" "Attrs" (dict
  "hx-post" "/api/save"
  "hx-target" "#toast-viewport"
  "hx-swap" "beforeend"
))}}

{{/* Server endpoint returns: */}}
{{template "toast" (dict "Title" "Saved" "Description" "Your changes are stored." "Variant" "success")}}
<.button hx-post={~p"/api/save"} hx-target="#toast-viewport" hx-swap="beforeend">
  Save
</.button>

# Server endpoint returns:
<.toast title="Saved" description="Your changes are stored." variant="success" />
<div class="flex flex-col items-center gap-3">
  <div class="flex flex-wrap gap-2">
    <button type="button" class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.htmx-request]:opacity-70 border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50 h-9 px-4 py-2 has-[&gt;svg]:px-3" data-slot="button" data-variant="outline" data-size="default" hx-post="/toast/flash?variant=success" hx-target="#ex-toast-viewport" hx-swap="beforeend">Flash success</button>
    <button type="button" class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.htmx-request]:opacity-70 border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50 h-9 px-4 py-2 has-[&gt;svg]:px-3" data-slot="button" data-variant="outline" data-size="default" hx-post="/toast/flash?variant=info" hx-target="#ex-toast-viewport" hx-swap="beforeend">Flash info</button>
    <button type="button" class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.htmx-request]:opacity-70 bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 h-9 px-4 py-2 has-[&gt;svg]:px-3" data-slot="button" data-variant="destructive" data-size="default" hx-post="/toast/flash?variant=destructive&amp;live=assertive" hx-target="#ex-toast-viewport" hx-swap="beforeend">Flash error</button>
  </div>
  <p class="text-xs text-muted-foreground">Toasts appear in the bottom-right of this docs page.</p>
</div>

Variants — match the visual to the urgency

Five visual variants. Pair each with the right live politeness — success/info/default should be polite, destructive often warrants assertive.

Don't make every toast assertive — screen-reader users will hate you. Reserve live="assertive" for actual errors that the user must hear immediately (save failed, connection lost). Success and info are polite; the AT announces them after the current speech finishes.

<Toast variant="default">…</Toast>
<Toast variant="success">…</Toast>
<Toast variant="warning">…</Toast>
<Toast variant="info">…</Toast>
<Toast variant="destructive" live="assertive">…</Toast>
{{ toast(title="…", variant="default") }}
{{ toast(title="…", variant="success") }}
{{ toast(title="…", variant="warning") }}
{{ toast(title="…", variant="info") }}
{{ toast(title="…", variant="destructive", live="assertive") }}
{{template "toast" (dict "Title" "…" "Variant" "default")}}
{{template "toast" (dict "Title" "…" "Variant" "success")}}
{{template "toast" (dict "Title" "…" "Variant" "warning")}}
{{template "toast" (dict "Title" "…" "Variant" "info")}}
{{template "toast" (dict "Title" "…" "Variant" "destructive" "Live" "assertive")}}
<.toast title="…" variant="default" />
<.toast title="…" variant="success" />
<.toast title="…" variant="warning" />
<.toast title="…" variant="info" />
<.toast title="…" variant="destructive" live="assertive" />
<div class="flex flex-wrap items-center justify-center gap-2">
  <button type="button" class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.htmx-request]:opacity-70 border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50 h-8 gap-1.5 rounded-md px-3 has-[&gt;svg]:px-2.5" data-slot="button" data-variant="outline" data-size="sm" hx-post="/toast/flash?variant=default" hx-target="#ex-toast-viewport" hx-swap="beforeend">default</button>
  <button type="button" class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.htmx-request]:opacity-70 border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50 h-8 gap-1.5 rounded-md px-3 has-[&gt;svg]:px-2.5" data-slot="button" data-variant="outline" data-size="sm" hx-post="/toast/flash?variant=success" hx-target="#ex-toast-viewport" hx-swap="beforeend">success</button>
  <button type="button" class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.htmx-request]:opacity-70 border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50 h-8 gap-1.5 rounded-md px-3 has-[&gt;svg]:px-2.5" data-slot="button" data-variant="outline" data-size="sm" hx-post="/toast/flash?variant=warning" hx-target="#ex-toast-viewport" hx-swap="beforeend">warning</button>
  <button type="button" class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.htmx-request]:opacity-70 border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50 h-8 gap-1.5 rounded-md px-3 has-[&gt;svg]:px-2.5" data-slot="button" data-variant="outline" data-size="sm" hx-post="/toast/flash?variant=info" hx-target="#ex-toast-viewport" hx-swap="beforeend">info</button>
  <button type="button" class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.htmx-request]:opacity-70 border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50 h-8 gap-1.5 rounded-md px-3 has-[&gt;svg]:px-2.5" data-slot="button" data-variant="outline" data-size="sm" hx-post="/toast/flash?variant=destructive&amp;live=assertive" hx-target="#ex-toast-viewport" hx-swap="beforeend">destructive</button>
</div>

Sticky toast — duration=0

Set duration to 0 and the toast stays put until the user clicks the X. Useful for important confirmations or actionable notices.

Default is 5 s. Set duration={0} when the message is too important to vanish (a server validation summary, a "review your draft" reminder) — the user must dismiss it manually. Pair with showClose={true} (the default) so the dismissal action is obvious.

<Toast variant="warning" duration={0}>
  <ToastTitle>Review draft</ToastTitle>
  <ToastDescription>Has unsaved changes from another tab.</ToastDescription>
</Toast>
{{ toast(title="Review draft",
          description="Has unsaved changes from another tab.",
          variant="warning", duration=0) }}
{{template "toast" (dict
  "Title" "Review draft" "Description" "Has unsaved changes from another tab."
  "Variant" "warning" "Duration" 0
)}}
<.toast title="Review draft"
        description="Has unsaved changes from another tab."
        variant="warning" duration={0} />
<div class="flex items-center justify-center">
  <button type="button" class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.htmx-request]:opacity-70 border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50 h-9 px-4 py-2 has-[&gt;svg]:px-3" data-slot="button" data-variant="outline" data-size="default" hx-post="/toast/flash?variant=warning&amp;duration=0" hx-target="#ex-toast-viewport" hx-swap="beforeend">Flash sticky warning</button>
</div>

    API Reference

    <Toast>

    PropTypeDefaultDescription
    closeHxobject
    Optional htmx attributes (hx-get/post/put/patch/delete/target/swap/trigger/indicator/confirm/vals) forwarded onto the close button so dismissing also notifies the server (mark-read, analytics) while site.js still removes the node.htmxAttribute reference
    variant"default"|"destructive"|"success"|"warning"|"info""default"
    Visual variant.
    durationnumber5000
    Auto-dismiss timeout in ms. 0 keeps it until user clicks the X.
    live"polite"|"assertive""polite"
    Live-region politeness.
    showClosebooleantrue
    Render the X close button.
    classstring
    Extra Tailwind classes appended to the root element.