Feedback

Announcement Pill

Announcement chip whose shine sweeps once on mount then on hover, with an arrow nudge and a width-collapse dismiss.

Install

npx shadcn@latest add @paragon/announcement-pill

announcement-pill.tsx

"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>
    </>
  );
}