Full-width announcement bar that expands in on mount, collapses on dismiss, and nudges its link arrow on hover.
npx shadcn@latest add @paragon/banner"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>
);
}