Feedback

Alert

Inline alert with info, success, warning, and destructive variants whose dismissal collapses height via grid rows plus a fade.

Install

npx shadcn@latest add @paragon/alert

alert.tsx

"use client";

import * as React from "react";
import {
  CircleCheck,
  Info,
  OctagonAlert,
  TriangleAlert,
  X,
} from "lucide-react";
import { cn } from "@/lib/utils";

type AlertVariant = "info" | "success" | "warning" | "destructive";

const variantIcon: Record<AlertVariant, React.ComponentType<{ className?: string }>> = {
  info: Info,
  success: CircleCheck,
  warning: TriangleAlert,
  destructive: OctagonAlert,
};

const variantIconColor: Record<AlertVariant, string> = {
  info: "text-blue-600 dark:text-blue-400",
  success: "text-emerald-600 dark:text-emerald-400",
  warning: "text-amber-600 dark:text-amber-400",
  destructive: "text-destructive",
};

export interface AlertProps extends Omit<React.ComponentProps<"div">, "title"> {
  variant?: AlertVariant;
  title: React.ReactNode;
  description?: React.ReactNode;
  /** Override the variant icon. Pass `null` to hide it. */
  icon?: React.ReactNode;
  /** Shows a dismiss button; exit collapses the alert's height and fades. */
  dismissible?: boolean;
  /** Called after the dismiss exit transition completes. */
  onDismiss?: () => void;
}

/**
 * Inline alert for surfacing state in context. Dismissal collapses via
 * grid-template-rows 1fr→0fr plus a fade, then unmounts.
 */
export function Alert({
  variant = "info",
  title,
  description,
  icon,
  dismissible = false,
  onDismiss,
  className,
  children,
  ...props
}: AlertProps) {
  const [state, setState] = React.useState<"open" | "closing" | "closed">(
    "open",
  );
  const Icon = variantIcon[variant];

  if (state === "closed") return null;

  return (
    <div
      className={cn(
        "grid transition-[grid-template-rows,opacity] duration-150 motion-reduce:transition-[opacity]",
        state === "closing"
          ? "grid-rows-[0fr] opacity-0 ease-[var(--ease-exit)] motion-reduce:grid-rows-[0fr]"
          : "grid-rows-[1fr] opacity-100",
      )}
      onTransitionEnd={(event) => {
        if (
          state === "closing" &&
          event.target === event.currentTarget &&
          event.propertyName === "opacity"
        ) {
          setState("closed");
          onDismiss?.();
        }
      }}
    >
      <div className="overflow-hidden">
        <div
          role={
            variant === "destructive" || variant === "warning"
              ? "alert"
              : "status"
          }
          className={cn(
            "flex w-full items-start gap-3 rounded-xl bg-card p-4 text-card-foreground shadow-border",
            className,
          )}
          {...props}
        >
          {icon !== null && (
            <span
              aria-hidden
              className={cn("mt-0.5 shrink-0", variantIconColor[variant])}
            >
              {icon ?? <Icon className="size-4" />}
            </span>
          )}
          <div className="min-w-0 flex-1">
            <p className="text-sm font-medium leading-5">{title}</p>
            {description && (
              <p className="mt-1 text-[13px] leading-5 text-muted-foreground">
                {description}
              </p>
            )}
            {children}
          </div>
          {dismissible && (
            <button
              type="button"
              aria-label="Dismiss"
              onClick={() => setState("closing")}
              className="pressable relative -m-1 flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors duration-150 hover:text-foreground after:absolute after:top-1/2 after:left-1/2 after:size-10 after:-translate-1/2"
            >
              <X className="size-3.5" />
            </button>
          )}
        </div>
      </div>
    </div>
  );
}