Inline alert with info, success, warning, and destructive variants whose dismissal collapses height via grid rows plus a 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-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>
);
}