Inline alert with info, success, warning, and destructive variants that enters and dismisses via a grid-rows collapse with a half-duration fade.
npx shadcn@latest add @paragon/alert"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>
);
}