Page Announcement
Feedback

Page Announcement

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.

Install

npx shadcn@latest add @paragon/page-announcement

page-announcement.tsx

"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>
  );
}