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. Enters by expanding grid-template-rows
 * 0fr→1fr with a fade; dismissal reverses it. The link arrow nudges on
 * hover (hover-capable pointers only).
 */
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]",
        state === "closing"
          ? "duration-150 ease-[var(--ease-exit)]"
          : "duration-250 ease-[var(--ease-out)]",
        "motion-reduce:transition-[opacity]",
        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 &&
          event.propertyName === "opacity"
        ) {
          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-transform duration-150 ease-[var(--ease-out)] group-hover:translate-x-0.5"
                />
              </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>
  );
}