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