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.json2. Use it
// 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
/** @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
{% 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
{# 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
{{/* 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
{{/*
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
# 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
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
<!-- 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
<!--
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 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:pointer-events-none [&.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-[>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 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:pointer-events-none [&.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-[>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 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:pointer-events-none [&.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-[>svg]:px-3" data-slot="button" data-variant="destructive" data-size="default" hx-post="/toast/flash?variant=destructive&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>Further reading
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 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:pointer-events-none [&.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-[>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 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:pointer-events-none [&.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-[>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 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:pointer-events-none [&.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-[>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 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:pointer-events-none [&.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-[>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 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:pointer-events-none [&.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-[>svg]:px-2.5" data-slot="button" data-variant="outline" data-size="sm" hx-post="/toast/flash?variant=destructive&live=assertive" hx-target="#ex-toast-viewport" hx-swap="beforeend">destructive</button>
</div>Further reading
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 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:pointer-events-none [&.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-[>svg]:px-3" data-slot="button" data-variant="outline" data-size="default" hx-post="/toast/flash?variant=warning&duration=0" hx-target="#ex-toast-viewport" hx-swap="beforeend">Flash sticky warning</button>
</div>Further reading
API Reference
<Toast>
| Prop | Type | Default | Description |
|---|---|---|---|
closeHx | object | — | 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. |
duration | number | 5000 | Auto-dismiss timeout in ms. 0 keeps it until user clicks the X. |
live | "polite"|"assertive" | "polite" | Live-region politeness. |
showClose | boolean | true | Render the X close button. |
class | string | — | Extra Tailwind classes appended to the root element. |