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

Live preview & controls

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

PropTypeDefaultDescription
variant"soft" | "outlined" | "filled"softSurface style.
tone"default" | "success" | "warning" | "destructive"defaultSemantic color.
toastbooleanfalseWhen true, renders as a portaled toast instead of inline.
openbooleantrueControls visibility.
placement"top-left" | "top-right" | "top-center" | "bottom-left" | "bottom-right" | "bottom-center"top-rightFixed corner when `toast` is true.
durationnumber5000Auto-close delay (ms) in toast mode; 0 disables.
persistentbooleanfalseToast mode only — disables auto-close and outside-click dismiss.
pauseOnHoverbooleantruePauses auto-close while hovered in toast mode.
live"assertive" | "polite"assertiveInline default is assertive; toast default is polite.
titleReact.ReactNodeBold heading line.
actionReact.ReactNodeOptional action slot below the description.
iconReact.ReactNode | falseOverride the tone icon, or false to hide.
dismissible / onDismissboolean / () => voidClose 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

Browse the full examples gallery