Banner
Feedback

Banner

Full-width announcement bar that expands in on mount, collapses on dismiss, and nudges its link arrow on hover.

Install

npx shadcn@latest add @paragon/banner

banner.tsx

"use client";

import * as React from "react";
import { ArrowRight, X } from "lucide-react";
import { cn } from "@/lib/utils";

export interface BannerProps extends React.ComponentProps<"div"> {
  /** Optional link rendered after the message. */
  href?: string;
  linkLabel?: string;
  /** Shows a dismiss button; dismissal collapses the bar's height. */
  dismissible?: boolean;
  /** Called after the dismiss exit transition completes. */
  onDismiss?: () => void;
  /** Skip the mount expand-in (e.g. when the banner is always present). */
  static?: boolean;
}

/**
 * Full-width announcement bar. Enter and exit run on the grid-template-rows
 * 0fr↔1fr collapse with the fade at half duration — entering, the bar opens
 * first and content fades in during the back half; exiting, content is gone
 * by the time the height settles. The link arrow nudges on hover. Reduced
 * motion keeps only the fade.
 */
export function Banner({
  href,
  linkLabel = "Learn more",
  dismissible = false,
  onDismiss,
  static: isStatic = false,
  className,
  children,
  ...props
}: BannerProps) {
  const [state, setState] = React.useState<
    "entering" | "open" | "closing" | "closed"
  >(isStatic ? "open" : "entering");

  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 w-full 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 bar 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",
      )}
      onTransitionEnd={(event) => {
        if (state !== "closing" || 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;
        setState("closed");
        onDismiss?.();
      }}
    >
      <div className="overflow-hidden">
        <div
          role="status"
          className={cn(
            "flex w-full items-center justify-center gap-3 bg-secondary px-4 py-2.5 text-secondary-foreground",
            className,
          )}
          {...props}
        >
          <p className="min-w-0 truncate text-[13px] leading-5">
            {children}
            {href && (
              <a
                href={href}
                className="group ml-2 inline-flex items-center gap-1 font-medium underline-offset-4 hover:underline"
              >
                {linkLabel}
                <ArrowRight
                  aria-hidden
                  className="size-3.5 transition-[translate] duration-150 ease-[var(--ease-out)] group-hover:translate-x-0.5 motion-reduce:group-hover:translate-x-0"
                />
              </a>
            )}
          </p>
          {dismissible && (
            <button
              type="button"
              aria-label="Dismiss announcement"
              onClick={() => setState("closing")}
              className="pressable relative 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>
  );
}