Full-width severity ribbon for incidents and maintenance — expands and collapses via grid rows so the page reflows, with a tabular countdown chip slot and alert semantics when critical.
npx shadcn@latest add @paragon/page-announcement"use client";
import * as React from "react";
import {
Megaphone,
OctagonAlert,
TriangleAlert,
Wrench,
X,
} from "lucide-react";
import { cn } from "@/lib/utils";
type AnnouncementSeverity = "info" | "warning" | "critical" | "maintenance";
const severityBand: Record<AnnouncementSeverity, string> = {
info: "bg-primary text-primary-foreground",
warning: "bg-warning text-warning-foreground",
critical: "bg-destructive text-destructive-foreground",
maintenance: "bg-secondary text-secondary-foreground",
};
const severityChip: Record<AnnouncementSeverity, string> = {
info: "bg-primary-foreground/15",
warning: "bg-warning-foreground/10",
critical: "bg-destructive-foreground/20",
maintenance: "bg-background/80 shadow-border",
};
const severityIcon: Record<
AnnouncementSeverity,
React.ComponentType<{ className?: string }>
> = {
info: Megaphone,
warning: TriangleAlert,
critical: OctagonAlert,
maintenance: Wrench,
};
export interface PageAnnouncementProps
extends Omit<React.ComponentProps<"div">, "title"> {
severity?: AnnouncementSeverity;
title: React.ReactNode;
description?: React.ReactNode;
/** Countdown / ETA chip content (e.g. a ticking "resolves in 12:40"). */
countdown?: React.ReactNode;
/** Trailing action — a status link, "View incident", etc. */
action?: React.ReactNode;
/** Show the dismiss button. Leave off for must-see incident banners. */
dismissible?: boolean;
/** Called after the collapse-out completes. */
onDismiss?: () => void;
}
/**
* Full-width takeover ribbon for the notices that outrank the page:
* incidents, maintenance windows, forced upgrades. Solid severity bands from
* the semantic tokens, a tabular countdown chip slot, and an expand-in /
* collapse-on-dismiss driven by grid-template-rows so page content reflows
* instead of jumping. Critical severity announces assertively
* (role="alert"); everything else stays polite.
*/
export function PageAnnouncement({
severity = "info",
title,
description,
countdown,
action,
dismissible = false,
onDismiss,
className,
...props
}: PageAnnouncementProps) {
const [state, setState] = React.useState<
"mounting" | "open" | "closing" | "closed"
>("mounting");
const Icon = severityIcon[severity];
// Expand in on mount: first paint at 0fr, then transition to 1fr.
React.useEffect(() => {
const frame = requestAnimationFrame(() =>
requestAnimationFrame(() =>
setState((current) => (current === "mounting" ? "open" : current)),
),
);
return () => cancelAnimationFrame(frame);
}, []);
if (state === "closed") return null;
return (
<div
className={cn(
"grid transition-[grid-template-rows,opacity] motion-reduce:transition-[opacity]",
state === "open" &&
"grid-rows-[1fr] opacity-100 duration-250 ease-[var(--ease-out)]",
state === "mounting" &&
"grid-rows-[0fr] opacity-0 duration-250 ease-[var(--ease-out)] motion-reduce:grid-rows-[1fr]",
state === "closing" &&
"grid-rows-[0fr] opacity-0 duration-150 ease-[var(--ease-exit)] motion-reduce:grid-rows-[0fr]",
)}
onTransitionEnd={(event) => {
if (
state === "closing" &&
event.target === event.currentTarget &&
event.propertyName === "opacity"
) {
setState("closed");
onDismiss?.();
}
}}
>
<div className="overflow-hidden">
<div
role={severity === "critical" ? "alert" : "status"}
data-severity={severity}
className={cn(
"flex w-full items-center gap-3 px-4 py-2.5",
severityBand[severity],
className,
)}
{...props}
>
<Icon aria-hidden className="size-4 shrink-0" />
<p className="min-w-0 flex-1 text-sm leading-5">
<span className="font-semibold">{title}</span>
{description && (
<span className="opacity-80"> — {description}</span>
)}
</p>
{countdown && (
<span
className={cn(
"hidden shrink-0 items-center rounded-md px-2 py-0.5 font-mono text-xs font-medium tabular-nums sm:flex",
severityChip[severity],
)}
>
{countdown}
</span>
)}
{action && (
<span className="shrink-0 text-sm font-medium underline underline-offset-4 [&_a]:rounded-sm [&_a]:outline-none [&_a:focus-visible]:ring-2 [&_a:focus-visible]:ring-current">
{action}
</span>
)}
{dismissible && (
<button
type="button"
aria-label="Dismiss announcement"
onClick={() => setState("closing")}
className="pressable relative -mr-1 flex size-6 shrink-0 items-center justify-center rounded-md opacity-70 transition-opacity duration-150 outline-none after:absolute after:top-1/2 after:left-1/2 after:size-10 after:-translate-1/2 hover:opacity-100 focus-visible:ring-2 focus-visible:ring-current"
>
<X className="size-3.5" />
</button>
)}
</div>
</div>
</div>
);
}