Composite
Alert
Alert renders inline in the document flow by default. Set `toast` to portal the same UI as a fixed toast (auto-close, placement, outside-click dismiss). Use `toast()` + `<Toaster />` for imperative stacked toasts.
Installation
npx shadcn add https://rck.vibeboxph.com//r/alert.jsonLive preview & controls
Heads up
This alert supports inline and toast rendering with shared styling.
Controls
<Alert title="Heads up" variant="soft" tone="default">This alert supports inline and toast rendering.</Alert>Anatomy
AlertInline banner (`toast={false}`) or portaled toast (`toast={true}`).AlertTitle / AlertDescriptionSemantic title and body text parts.AlertContentShared inner layout for inline and toast modes.ToasterOptional — renders imperative `toast()` stack.toast()Imperative function for stacked notifications.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| variant | "soft" | "outlined" | "filled" | soft | Surface style. |
| tone | "default" | "success" | "warning" | "destructive" | default | Semantic color. |
| toast | boolean | false | When true, renders as a portaled toast instead of inline. |
| open | boolean | true | Controls visibility. |
| placement | "top-left" | "top-right" | "top-center" | "bottom-left" | "bottom-right" | "bottom-center" | top-right | Fixed corner when `toast` is true. |
| duration | number | 5000 | Auto-close delay (ms) in toast mode; 0 disables. |
| persistent | boolean | false | Toast mode only — disables auto-close and outside-click dismiss. |
| pauseOnHover | boolean | true | Pauses auto-close while hovered in toast mode. |
| live | "assertive" | "polite" | assertive | Inline default is assertive; toast default is polite. |
| title | React.ReactNode | — | Bold heading line. |
| action | React.ReactNode | — | Optional action slot below the description. |
| icon | React.ReactNode | false | — | Override the tone icon, or false to hide. |
| dismissible / onDismiss | boolean / () => void | — | Close button; defaults to true in toast mode. |
Accessibility
- Default `live="assertive"` uses `role="alert"` for urgent, interruptive messages.
- Use `live="polite"` with `role="status"` for confirmations and background saves that shouldn't interrupt.
- Dismiss button uses an explicit `aria-label="Dismiss alert"`.
- Toasts use built-in live-region semantics with labelled close buttons and swipe/keyboard dismissal.
- Auto-dismiss timers pause on hover/focus so users have time to read before a toast disappears.
Source
registry/rck/ui/alert.tsx
"use client";
import * as React from "react";
import * as ReactDOM from "react-dom";
import * as ToastPrimitive from "@/registry/rck/primitives/toast";
import { cva, type VariantProps } from "@/lib/cva";
import { CircleCheck, Info, TriangleAlert, X, CircleX } from "@/lib/icons";
import { cn } from "@/lib/utils";
import {
useToast,
type ToastItem,
type ToastPlacement,
type ToastTone,
} from "@/hooks/use-toast";
export const ALERT_EXIT_DURATION = 220;
export const alertVariants = cva(
"relative flex w-full gap-3 rounded-lg border p-4 text-sm [&_svg]:size-4.5 [&_svg]:shrink-0",
{
variants: {
variant: {
soft: "",
outlined: "bg-transparent",
filled: "border-transparent",
},
tone: {
default: "",
success: "",
warning: "",
destructive: "",
},
},
compoundVariants: [
{
variant: "soft",
tone: "default",
class: "border-border bg-surface-2 text-foreground",
},
{
variant: "soft",
tone: "success",
class: "border-success/30 bg-success/10 text-success",
},
{
variant: "soft",
tone: "warning",
class: "border-warning/30 bg-warning/10 text-warning",
},
{
variant: "soft",
tone: "destructive",
class: "border-destructive/30 bg-destructive/10 text-destructive",
},
{ variant: "outlined", tone: "default", class: "border-border text-foreground" },
{ variant: "outlined", tone: "success", class: "border-success/50 text-success" },
{ variant: "outlined", tone: "warning", class: "border-warning/50 text-warning" },
{
variant: "outlined",
tone: "destructive",
class: "border-destructive/50 text-destructive",
},
{ variant: "filled", tone: "default", class: "bg-foreground text-background" },
{ variant: "filled", tone: "success", class: "bg-success text-success-foreground" },
{ variant: "filled", tone: "warning", class: "bg-warning text-warning-foreground" },
{
variant: "filled",
tone: "destructive",
class: "bg-destructive text-destructive-foreground",
},
],
defaultVariants: {
variant: "soft",
tone: "default",
},
},
);
const toneIcon = {
default: Info,
success: CircleCheck,
warning: TriangleAlert,
destructive: CircleX,
} as const;
const toastPlacementClasses: Record<ToastPlacement, string> = {
"top-left": "top-4 left-4 items-start",
"top-right": "top-4 right-4 items-end",
"top-center": "top-4 left-1/2 -translate-x-1/2 items-center",
"bottom-left": "bottom-4 left-4 items-start",
"bottom-right": "bottom-4 right-4 items-end",
"bottom-center": "bottom-4 left-1/2 -translate-x-1/2 items-center",
};
export interface AlertProps
extends
Omit<React.HTMLAttributes<HTMLDivElement>, "title">,
VariantProps<typeof alertVariants> {
title?: React.ReactNode;
icon?: React.ReactNode | false;
action?: React.ReactNode;
/** Shows a dismiss button. Defaults to true in toast mode. */
dismissible?: boolean;
onDismiss?: () => void;
/** When true, renders as a fixed toast via portal instead of inline in the document flow. */
toast?: boolean;
/** Controls visibility. Defaults to true. */
open?: boolean;
/** Toast corner placement — only used when `toast` is true. */
placement?: ToastPlacement;
/** Auto-close delay in ms for toast mode. Set to 0 to disable. */
duration?: number;
/** Pauses auto-close while hovered in toast mode. */
pauseOnHover?: boolean;
/**
* When true in toast mode, disables auto-close and outside-click dismiss
* (matches rc `closeable`).
*/
persistent?: boolean;
/** `assertive` maps to `role="alert"`; `polite` to `role="status"`. */
live?: "assertive" | "polite";
}
export function AlertTitle({
className,
...props
}: React.HTMLAttributes<HTMLParagraphElement>) {
return (
<p
data-slot="alert-title"
className={cn("leading-none font-medium tracking-tight", className)}
{...props}
/>
);
}
export function AlertDescription({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
data-slot="alert-description"
className={cn("text-sm text-current/90 [&_p]:leading-relaxed", className)}
{...props}
/>
);
}
interface AlertContentProps {
tone?: ToastTone | null;
variant?: VariantProps<typeof alertVariants>["variant"];
title?: React.ReactNode;
icon?: React.ReactNode | false;
action?: React.ReactNode;
dismissible?: boolean;
onDismiss?: () => void;
live?: "assertive" | "polite";
children?: React.ReactNode;
}
export function AlertContent({
tone = "default",
title,
icon,
action,
dismissible,
onDismiss,
live = "assertive",
children,
}: AlertContentProps) {
const resolvedTone = tone ?? "default";
const ToneIcon = toneIcon[resolvedTone];
const resolvedIcon = icon === false ? null : (icon ?? <ToneIcon aria-hidden="true" />);
return (
<>
{resolvedIcon}
<div className="flex-1 space-y-1">
{title && <AlertTitle>{title}</AlertTitle>}
{children && <AlertDescription>{children}</AlertDescription>}
{action && <div className="pt-1">{action}</div>}
</div>
{dismissible && (
<button
type="button"
onClick={onDismiss}
aria-label="Dismiss alert"
className="focus-ring shrink-0 rounded-sm p-0.5 text-current/70 hover:text-current"
>
<X className="size-4" />
</button>
)}
</>
);
}
function AlertSurface({
className,
variant,
tone,
title,
icon,
action,
dismissible,
onDismiss,
live,
toast,
closing,
children,
onMouseEnter,
onMouseLeave,
role: roleProp,
containerRef,
open: _open,
placement: _placement,
duration: _duration,
pauseOnHover: _pauseOnHover,
persistent: _persistent,
...props
}: AlertProps & {
closing?: boolean;
containerRef?: React.Ref<HTMLDivElement>;
onMouseEnter?: React.MouseEventHandler<HTMLDivElement>;
onMouseLeave?: React.MouseEventHandler<HTMLDivElement>;
}) {
const role = roleProp ?? (live === "polite" ? "status" : "alert");
return (
<div
ref={containerRef}
data-slot={toast ? "alert-toast" : "alert"}
role={role}
aria-live={live === "polite" ? "polite" : undefined}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
className={cn(
alertVariants({ variant, tone }),
toast && "max-w-sm shadow-lg",
!toast && "shadow-none",
toast &&
(closing
? "animate-out fade-out-0 zoom-out-95 duration-200"
: "animate-in fade-in-0 zoom-in-95 duration-200"),
className,
)}
{...props}
>
<AlertContent
tone={tone ?? "default"}
variant={variant}
title={title}
icon={icon}
action={action}
dismissible={dismissible}
onDismiss={onDismiss}
live={live}
>
{children}
</AlertContent>
</div>
);
}
function AlertToast({
className,
variant,
tone = "default",
title,
icon,
action,
dismissible = true,
onDismiss,
open: openProp = true,
placement = "top-right",
duration = 5000,
pauseOnHover = true,
persistent = false,
live = "polite",
children,
role: roleProp,
...props
}: AlertProps) {
const mounted = React.useSyncExternalStore(
() => () => {},
() => true,
() => false,
);
const [visible, setVisible] = React.useState(openProp);
const [closing, setClosing] = React.useState(false);
const containerRef = React.useRef<HTMLDivElement>(null);
const exitTimerRef = React.useRef<number | null>(null);
const autoCloseTimerRef = React.useRef<number | null>(null);
const autoCloseStartRef = React.useRef(0);
const autoCloseRemainingRef = React.useRef(duration);
const clearExitTimer = React.useCallback(() => {
if (exitTimerRef.current) {
window.clearTimeout(exitTimerRef.current);
exitTimerRef.current = null;
}
}, []);
const clearAutoCloseTimer = React.useCallback(() => {
if (autoCloseTimerRef.current) {
window.clearTimeout(autoCloseTimerRef.current);
autoCloseTimerRef.current = null;
}
}, []);
const finishClose = React.useCallback(() => {
setVisible(false);
setClosing(false);
exitTimerRef.current = null;
}, []);
const requestClose = React.useCallback(() => {
if (closing) return;
setClosing(true);
clearExitTimer();
exitTimerRef.current = window.setTimeout(() => {
finishClose();
onDismiss?.();
}, ALERT_EXIT_DURATION);
}, [clearExitTimer, closing, finishClose, onDismiss]);
React.useEffect(() => {
if (openProp) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- reopen resets exit animation state
setVisible(true);
setClosing(false);
clearExitTimer();
return;
}
if (visible && !closing) {
requestClose();
}
}, [openProp, visible, closing, requestClose, clearExitTimer]);
React.useEffect(() => {
return () => {
clearExitTimer();
clearAutoCloseTimer();
};
}, [clearAutoCloseTimer, clearExitTimer]);
const startAutoClose = React.useCallback(
(delay: number) => {
if (persistent || duration <= 0) return;
clearAutoCloseTimer();
autoCloseStartRef.current = Date.now();
autoCloseRemainingRef.current = delay;
autoCloseTimerRef.current = window.setTimeout(() => {
autoCloseTimerRef.current = null;
requestClose();
}, delay);
},
[clearAutoCloseTimer, duration, persistent, requestClose],
);
React.useEffect(() => {
if (!visible || closing || persistent || duration <= 0) {
clearAutoCloseTimer();
return;
}
startAutoClose(duration);
return () => clearAutoCloseTimer();
}, [visible, closing, persistent, duration, startAutoClose, clearAutoCloseTimer]);
React.useEffect(() => {
if (!(visible && !persistent && !closing)) return;
function onDocPointerDown(event: Event) {
const element = containerRef.current;
const target = event.target as Node | null;
if (!element || !target) return;
if (element === target || element.contains(target)) return;
requestClose();
}
document.addEventListener("pointerdown", onDocPointerDown, true);
return () => document.removeEventListener("pointerdown", onDocPointerDown, true);
}, [visible, persistent, closing, requestClose]);
const handleMouseEnter = () => {
if (!pauseOnHover || persistent || duration <= 0 || !autoCloseTimerRef.current)
return;
const elapsed = Date.now() - autoCloseStartRef.current;
autoCloseRemainingRef.current = Math.max(autoCloseRemainingRef.current - elapsed, 0);
clearAutoCloseTimer();
};
const handleMouseLeave = () => {
if (!pauseOnHover || persistent || duration <= 0 || closing) return;
if (autoCloseRemainingRef.current > 0 && !autoCloseTimerRef.current) {
startAutoClose(autoCloseRemainingRef.current);
}
};
if (!mounted || !visible) return null;
return ReactDOM.createPortal(
<div
className={cn(
"pointer-events-none fixed z-[100] flex w-full max-w-sm flex-col gap-2 p-0",
toastPlacementClasses[placement],
)}
>
<AlertSurface
{...props}
toast
className={cn("pointer-events-auto", className)}
variant={variant}
tone={tone}
title={title}
icon={icon}
action={action}
dismissible={dismissible}
onDismiss={requestClose}
live={live}
closing={closing}
containerRef={containerRef}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
role={roleProp}
>
{children}
</AlertSurface>
</div>,
document.body,
);
}
const InlineAlertSurface = React.forwardRef<HTMLDivElement, AlertProps>(
function InlineAlertSurface(props, ref) {
return <AlertSurface {...props} toast={false} containerRef={ref} />;
},
);
export const Alert = React.forwardRef<HTMLDivElement, AlertProps>(
(
{ toast = false, open, placement, duration, pauseOnHover, persistent, ...props },
ref,
) => {
if (toast) {
return (
<AlertToast
{...props}
toast
open={open}
placement={placement}
duration={duration}
pauseOnHover={pauseOnHover}
persistent={persistent}
/>
);
}
return <InlineAlertSurface {...props} ref={ref} />;
},
);
Alert.displayName = "Alert";
function ToastAlertItem({
item,
onDismiss,
}: {
item: ToastItem;
onDismiss: (id: string) => void;
}) {
return (
<ToastPrimitive.Root
open={item.open}
duration={item.duration}
onOpenChange={(open) => {
if (!open) onDismiss(item.id);
}}
className={cn(
alertVariants({ variant: item.alertVariant, tone: item.tone }),
item.className,
"pointer-events-auto shadow-lg",
"data-[state=open]:animate-in data-[state=open]:slide-in-from-bottom-2",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0",
"data-[swipe=end]:animate-out",
)}
role={item.live === "polite" ? "status" : "alert"}
aria-live={item.live === "polite" ? "polite" : undefined}
>
<AlertContent
tone={item.tone}
variant={item.alertVariant}
title={item.title}
icon={item.icon}
action={item.action}
dismissible={item.dismissible}
live={item.live}
onDismiss={() => onDismiss(item.id)}
>
{item.description}
</AlertContent>
</ToastPrimitive.Root>
);
}
export interface ToasterProps {
placement?: ToastPlacement;
}
const viewportPlacementClasses: Record<ToastPlacement, string> = {
"top-left": "top-0 left-0 items-start",
"top-right": "top-0 right-0 items-end",
"top-center": "top-0 left-1/2 -translate-x-1/2 items-center",
"bottom-left": "bottom-0 left-0 items-start",
"bottom-right": "bottom-0 right-0 items-end",
"bottom-center": "bottom-0 left-1/2 -translate-x-1/2 items-center",
};
/** Renders imperative toasts from `toast()`. Mount once near the root of the app. */
export function Toaster({ placement = "bottom-right" }: ToasterProps) {
const { toasts, dismiss } = useToast();
const placements = React.useMemo(() => {
const active = new Set<ToastPlacement>();
toasts.forEach((item) => active.add(item.placement ?? placement));
return [...active];
}, [toasts, placement]);
if (!toasts.length) return null;
return (
<ToastPrimitive.Provider swipeDirection="right">
{toasts.map((item) => (
<ToastAlertItem key={item.id} item={item} onDismiss={dismiss} />
))}
{placements.map((corner) => (
<ToastPrimitive.Viewport
key={corner}
className={cn(
"pointer-events-none fixed z-[100] flex w-full max-w-sm flex-col gap-2 p-4 outline-none",
viewportPlacementClasses[corner],
)}
/>
))}
</ToastPrimitive.Provider>
);
}
hooks/use-toast.ts
"use client";
import * as React from "react";
export type ToastPlacement =
| "top-left"
| "top-right"
| "top-center"
| "bottom-left"
| "bottom-right"
| "bottom-center";
export type ToastTone = "default" | "success" | "warning" | "destructive";
export interface ToastOptions {
id?: string;
title?: React.ReactNode;
description?: React.ReactNode;
action?: React.ReactNode;
/** Semantic tone — matches Alert `tone`. Legacy shorthand: `variant: "destructive"` maps here. */
tone?: ToastTone;
/** @deprecated Prefer `tone`. Kept for backward compatibility with imperative toast(). */
variant?: ToastTone;
/** Alert surface style — matches Alert `variant`. */
alertVariant?: "soft" | "outlined" | "filled";
icon?: React.ReactNode | false;
dismissible?: boolean;
live?: "assertive" | "polite";
duration?: number;
placement?: ToastPlacement;
className?: string;
onDismiss?: () => void;
}
export interface ToastItem extends Required<Pick<ToastOptions, "id">> {
title?: React.ReactNode;
description?: React.ReactNode;
action?: React.ReactNode;
tone: ToastTone;
alertVariant: NonNullable<ToastOptions["alertVariant"]>;
icon?: React.ReactNode | false;
dismissible: boolean;
live: NonNullable<ToastOptions["live"]>;
duration: number;
placement: ToastPlacement;
className?: string;
onDismiss?: () => void;
open: boolean;
}
const TOAST_LIMIT = 4;
type Listener = (toasts: ToastItem[]) => void;
let memoryState: ToastItem[] = [];
const listeners = new Set<Listener>();
let idCount = 0;
function genId() {
idCount += 1;
return `toast-${idCount}`;
}
function emit() {
listeners.forEach((listener) => listener(memoryState));
}
function normalizeOptions(options: ToastOptions): Omit<ToastItem, "id" | "open"> {
const tone = options.tone ?? options.variant ?? "default";
return {
title: options.title,
description: options.description,
action: options.action,
tone,
alertVariant: options.alertVariant ?? "soft",
icon: options.icon,
dismissible: options.dismissible ?? true,
live: options.live ?? "polite",
duration: options.duration ?? 5000,
placement: options.placement ?? "bottom-right",
className: options.className,
onDismiss: options.onDismiss,
};
}
function addToast(options: ToastOptions) {
const id = options.id ?? genId();
const item: ToastItem = {
id,
...normalizeOptions(options),
open: true,
};
memoryState = [item, ...memoryState.filter((toast) => toast.id !== id)].slice(
0,
TOAST_LIMIT,
);
emit();
return id;
}
function updateToast(id: string, options: ToastOptions) {
const existing = memoryState.find((toast) => toast.id === id);
if (!existing) {
return addToast({ ...options, id });
}
const normalized = normalizeOptions({ ...existing, ...options, id });
memoryState = memoryState.map((toast) =>
toast.id === id ? { ...toast, ...normalized, id, open: toast.open } : toast,
);
emit();
return id;
}
function upsertToast(options: ToastOptions & { open?: boolean }) {
const id = options.id ?? genId();
const existing = memoryState.find((toast) => toast.id === id);
if (existing) {
const normalized = normalizeOptions({ ...existing, ...options, id });
memoryState = memoryState.map((toast) =>
toast.id === id
? { ...toast, ...normalized, id, open: options.open ?? toast.open }
: toast,
);
emit();
return id;
}
const item: ToastItem = {
id,
...normalizeOptions(options),
open: options.open ?? true,
};
memoryState = [item, ...memoryState].slice(0, TOAST_LIMIT);
emit();
return id;
}
function dismissToast(id: string) {
const target = memoryState.find((toast) => toast.id === id);
target?.onDismiss?.();
memoryState = memoryState.map((toast) =>
toast.id === id ? { ...toast, open: false } : toast,
);
emit();
window.setTimeout(() => {
memoryState = memoryState.filter((toast) => toast.id !== id);
emit();
}, 200);
}
export function toast(options: ToastOptions) {
const id = addToast(options);
return {
id,
dismiss: () => dismissToast(id),
update: (next: ToastOptions) => updateToast(id, next),
};
}
export function upsertToastItem(options: ToastOptions & { open?: boolean }) {
return upsertToast(options);
}
export function dismissToastItem(id: string) {
dismissToast(id);
}
export function resetToastStore() {
memoryState = [];
emit();
}
export function useToast() {
const toasts = React.useSyncExternalStore(
(callback) => {
listeners.add(callback);
return () => {
listeners.delete(callback);
};
},
() => memoryState,
() => memoryState,
);
return {
toasts,
toast,
dismiss: dismissToast,
upsert: upsertToast,
update: updateToast,
};
}
Related examples
Inline alertsAll tones and variants, including dismissible.
With actionsInline alert with a call-to-action button.
Live regionsPolite vs assertive announcement modes.
Dismissible toast stackImperative toast() and declarative Alert toast prop.
Controlled toastOpen/close a toast alert with external state.