Announcement chip whose shine sweeps once on mount then on hover, with an arrow nudge and a width-collapse dismiss.
npx shadcn@latest add @paragon/announcement-pill"use client";
import * as React from "react";
import { ArrowRight, X } from "lucide-react";
import { cn } from "@/lib/utils";
export interface AnnouncementPillProps extends React.ComponentProps<"a"> {
/** Leading badge text. */
label?: string;
/** Shows the dismiss button. */
dismissible?: boolean;
/** Fires after the dismiss collapse finishes. */
onDismiss?: () => void;
/** Disables the shine, nudge, and collapse motion. */
static?: boolean;
}
/**
* A "New" announcement chip for hero/nav placement. The shine sweeps exactly
* once on mount (announcements announce once), then only on hover; the arrow
* nudges on hover. Dismissing collapses the pill's width via the
* grid-template-columns 1fr -> 0fr track trick — the sanctioned sibling of
* the accordion row exception — so neighbors slide in smoothly, with a plain
* fade under prefers-reduced-motion.
*/
export function AnnouncementPill({
label = "New",
dismissible = false,
onDismiss,
static: isStatic = false,
className,
children,
...props
}: AnnouncementPillProps) {
const [state, setState] = React.useState<"open" | "closing" | "closed">(
"open",
);
const showDismiss = dismissible || onDismiss !== undefined;
const finalize = () => {
setState("closed");
onDismiss?.();
};
if (state === "closed") return null;
return (
<>
<style href="paragon-announcement-pill" precedence="paragon">{`
@keyframes pg-ap-shine {
from { translate: -100% 0; }
to { translate: 100% 0; }
}
@media (prefers-reduced-motion: reduce) {
[data-ap-shine] {
animation: none !important;
transition: none !important;
}
}
`}</style>
<span
data-slot="announcement-pill"
className={cn(
"inline-grid",
!isStatic &&
"transition-[grid-template-columns,opacity] duration-(--duration-quick) ease-(--ease-exit) motion-reduce:transition-[opacity]",
)}
style={{
gridTemplateColumns: state === "closing" ? "0fr" : "1fr",
opacity: state === "closing" ? 0 : 1,
}}
onTransitionEnd={(event) => {
if (event.target === event.currentTarget && state === "closing") {
finalize();
}
}}
>
<span className="flex min-w-0 items-center overflow-hidden">
<a
className={cn(
"group/pill relative inline-flex shrink-0 items-center gap-2 overflow-hidden rounded-full bg-card py-1 pr-3 pl-1 text-xs shadow-border transition-[box-shadow] duration-150 ease-out hover:shadow-border-hover",
className,
)}
{...props}
>
<span className="rounded-full bg-primary px-2 py-0.5 text-[11px] font-medium text-primary-foreground">
{label}
</span>
<span className="font-medium whitespace-nowrap text-foreground">
{children}
</span>
<ArrowRight
aria-hidden
className={cn(
"size-3 shrink-0 text-muted-foreground",
!isStatic &&
"transition-[translate] duration-150 ease-out group-hover/pill:translate-x-0.5 motion-reduce:group-hover/pill:translate-x-0",
)}
/>
{!isStatic && (
<span
aria-hidden
className="pointer-events-none absolute inset-0 overflow-hidden rounded-full"
>
{/* Sweeps once on mount (keyframes, backwards fill), then on
hover via a transition that only exists while hovered —
so un-hover resets instantly instead of sweeping back. */}
<span
data-ap-shine=""
className="absolute inset-0 -translate-x-full group-hover/pill:translate-x-full group-hover/pill:transition-[translate] group-hover/pill:duration-700 group-hover/pill:ease-(--ease-in-out)"
style={{
background:
"linear-gradient(115deg, transparent 35%, color-mix(in oklab, var(--color-foreground) 12%, transparent) 50%, transparent 65%)",
animation: `pg-ap-shine 900ms var(--ease-in-out) 400ms 1 backwards`,
}}
/>
</span>
)}
</a>
{showDismiss && (
<button
type="button"
aria-label="Dismiss announcement"
onClick={() => {
if (isStatic) {
finalize();
} else {
setState("closing");
}
}}
className="relative ml-1 flex size-6 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors duration-150 ease-out hover:text-foreground after:absolute after:top-1/2 after:left-1/2 after:size-10 after:-translate-1/2"
>
<X className="size-3" />
</button>
)}
</span>
</span>
</>
);
}