Alert
Feedback

Alert

Inline alert with info, success, warning, and destructive variants that enters and dismisses via a grid-rows collapse with a half-duration 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-primary",
  success: "text-success",
  warning: "text-warning",
  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;
  /** Animate in on mount: the row expands and the content fades in late. */
  animateIn?: boolean;
  /** Extra ms before the enter starts — for staggering stacked alerts. */
  enterDelay?: number;
}

/**
 * Inline alert for surfacing state in context. Enter and exit both run on
 * the grid-template-rows 0fr↔1fr collapse with the fade at half duration:
 * entering, the row opens first and content fades in during the second half;
 * exiting, content fades out in the first half so it never squashes while
 * the height settles. Reduced motion keeps only the fade.
 */
export function Alert({
  variant = "info",
  title,
  description,
  icon,
  dismissible = false,
  onDismiss,
  animateIn = false,
  enterDelay = 0,
  className,
  children,
  ...props
}: AlertProps) {
  const [state, setState] = React.useState<
    "entering" | "open" | "closing" | "closed"
  >(animateIn ? "entering" : "open");
  // Stagger delay applies only to the enter transition, never the dismiss.
  const [enterDone, setEnterDone] = React.useState(!animateIn);
  const Icon = variantIcon[variant];

  React.useEffect(() => {
    if (state !== "entering") return;
    // Two frames so the collapsed initial styles paint before expanding.
    const raf = requestAnimationFrame(() =>
      requestAnimationFrame(() => setState("open")),
    );
    return () => cancelAnimationFrame(raf);
  }, [state]);

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

  const collapsed = state === "entering" || state === "closing";

  return (
    <div
      className={cn(
        "grid transition-[grid-template-rows,opacity] motion-reduce:transition-[opacity] motion-reduce:[transition-delay:0ms] motion-reduce:[transition-duration:150ms]",
        state === "closing"
          ? // Exit: fade completes at half the collapse duration.
            "ease-(--ease-exit) [transition-delay:0ms,0ms] [transition-duration:150ms,75ms]"
          : // Enter: the row opens over 250ms; content fades in the back half.
            "ease-(--ease-out) [transition-delay:0ms,125ms] [transition-duration:250ms,125ms]",
        collapsed
          ? "grid-rows-[0fr] opacity-0 motion-reduce:grid-rows-[1fr]"
          : "grid-rows-[1fr] opacity-100",
      )}
      style={
        enterDone || enterDelay <= 0
          ? undefined
          : { transitionDelay: `${enterDelay}ms, ${enterDelay + 125}ms` }
      }
      onTransitionEnd={(event) => {
        if (event.target !== event.currentTarget) return;
        // The grid transition outlives the fade; under reduced motion only
        // opacity transitions, so accept it as the terminal event there.
        const terminal =
          event.propertyName === "grid-template-rows" ||
          (event.propertyName === "opacity" &&
            window.matchMedia("(prefers-reduced-motion: reduce)").matches);
        if (!terminal) return;
        if (state === "open") setEnterDone(true);
        if (state === "closing") {
          setState("closed");
          onDismiss?.();
        }
      }}
    >
      {/* The collapse clip must hide the row as it opens/closes, but flush to
          the card it would also clip the depth-border ring + drop shadow. The
          p-2/-m-2 pair widens the clip region 8px around the card (> the ~6px
          shadow spread) so the border is never cut, while overflow-hidden still
          forces the 0fr row to 0px — the collapse is unchanged. Layout-neutral. */}
      <div className="-m-2 overflow-hidden p-2">
        <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={() => {
                setEnterDone(true);
                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>
  );
}