Announcement pill

"New: …" above your headline that opens into the news: the pill itself grows into a changelog card.

Install with the shadcn CLI

$ npx shadcn@latest add https://hairlineui.com/r/announcement-pill.json

Overview

Every launch hero has a little "New" pill, and it’s usually a plain link. This one rewards curiosity. Point at it and the arrow nudges on while a second phrase folds open. Click it and the pill itself grows into a card with the details: its size and corners ease from pill to card while the line cross-fades into the content. Close it and it folds back into the pill. Without card content it’s simply a link.

States

rest
A tinted badge and one line in a hairline pill.
hover
The arrow slides 2px on and "· Read the changelog" folds open from zero width (500ms); the edge darkens a touch.
open
The pill grows into a 360px card (420ms), centred on where the pill was; the card’s first link takes focus.
close
Escape, the close button or a click outside folds the card back into the pill and returns focus.

Usage

import { AnnouncementPill } from "@/components/announcement-pill";

<AnnouncementPill more="Read the changelog" card={<Changelog />}>
  Agents can now hand off
</AnnouncementPill>

In a hero

Put it above the headline; pass a card for the details, or an href to link out.

import { AnnouncementPill } from "@/components/announcement-pill";

export function Hero({ latest }: { latest: { title: string; items: { date: string; title: string }[] } }) {
  return (
    <section className="flex flex-col items-center pt-24 text-center">
      <AnnouncementPill
        more="Read the changelog"
        card={
          <div className="p-5">
            <p className="text-xs text-muted-foreground">What’s new</p>
            <ol className="mt-3 space-y-3">
              {latest.items.map((item) => (
                <li key={item.title} className="text-sm">
                  <span className="mr-2 font-mono text-xs text-muted-foreground">{item.date}</span>
                  {item.title}
                </li>
              ))}
            </ol>
            <a href="/changelog" className="mt-4 inline-block text-sm underline underline-offset-4">See all updates</a>
          </div>
        }
      >
        {latest.title}
      </AnnouncementPill>
      <h1 className="mt-6 text-6xl font-medium tracking-tight">Ship the launch page tonight.</h1>
    </section>
  );
}

Props

PropTypeDescription
childrenReactNodeThe announcement line.
badgestringdefault "New"The tinted label at the start.
morestringA second phrase that folds open on hover or keyboard focus.
card / cardWidthReactNode / numberdefault — / 360What the pill opens into, and how wide (never wider than the screen).
hrefstringWithout a card, the pill links here.

Accessibility and motion

  • With a card, the pill is a button with aria-expanded; the card is a labelled dialog and its first link takes focus when it opens.
  • Escape, the close button or a click outside closes it, and focus returns to the pill.
  • The hover phrase also opens on keyboard focus, so nobody misses it.
  • With reduced motion the card simply appears and disappears.

Requirements

React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style). Icons from lucide-react.

Source

›Show the full source of announcement-pill.tsx (237 lines)
"use client";

import * as React from "react";

/* ─────────────────────────────────────────────────────────
 * ANNOUNCEMENT PILL: "New: …" that opens into the news
 *
 *   rest    a badge and one line, in a hairline pill
 *   hover   the arrow nudges on and a second phrase folds open
 *           ("Read the changelog")
 *   open    the pill itself grows into a card with the details:
 *           its size and corners ease from pill to card while
 *           the line cross-fades into the content
 *   close   the card folds back into the pill
 *
 * Without card content it's simply a link.
 * ───────────────────────────────────────────────────────── */

export interface AnnouncementPillProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
  badge?: string;
  children: React.ReactNode;
  /** A second phrase that folds open on hover. */
  more?: string;
  /** Without card, the pill is a link to here. */
  href?: string;
  /** What the pill opens into. */
  card?: React.ReactNode;
  cardWidth?: number;
}

type Phase = "closed" | "opening" | "open" | "closing";

const EASE = "cubic-bezier(0.16,1,0.3,1)";
const DURATION = 420;
const FOCUS = "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background";

const reducedQuery = "(prefers-reduced-motion: reduce)";
const subscribeReduced = (onChange: () => void) => {
  const query = window.matchMedia(reducedQuery);
  query.addEventListener("change", onChange);
  return () => query.removeEventListener("change", onChange);
};
const useReducedMotion = () =>
  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);

function Arrow() {
  return (
    <svg aria-hidden="true" viewBox="0 0 12 12" className="size-3 shrink-0 text-muted-foreground transition-[transform,color] duration-300 group-hover/pill:translate-x-0.5 group-hover/pill:text-foreground">
      <path d="M2.5 6h7M6.5 3l3 3-3 3" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

export function AnnouncementPill({ badge = "New", children, more, href, card, cardWidth = 360, className = "", ...props }: AnnouncementPillProps) {
  const reduced = useReducedMotion();
  const id = React.useId();
  const [phase, setPhase] = React.useState<Phase>("closed");
  const [pill, setPill] = React.useState({ width: 0, height: 32 });
  const [cardSize, setCardSize] = React.useState({ width: cardWidth, height: 0 });
  const [moreWidth, setMoreWidth] = React.useState(0);
  const wrapRef = React.useRef<HTMLDivElement>(null);
  const pillRef = React.useRef<HTMLElement | null>(null);
  const cardRef = React.useRef<HTMLDivElement>(null);
  const moreRef = React.useRef<HTMLSpanElement>(null);
  const timer = React.useRef(0);

  const expanded = phase === "open";
  const mounted = phase !== "closed";

  React.useLayoutEffect(() => {
    const el = pillRef.current;
    if (!el) return;
    const measure = () => setPill((p) => (p.width === el.offsetWidth && p.height === el.offsetHeight ? p : { width: el.offsetWidth, height: el.offsetHeight }));
    measure();
    const observer = new ResizeObserver(measure);
    observer.observe(el);
    return () => observer.disconnect();
  }, []);

  React.useLayoutEffect(() => {
    const el = moreRef.current;
    if (el) setMoreWidth(el.offsetWidth);
  }, [more]);

  React.useLayoutEffect(() => {
    const el = cardRef.current;
    if (!el) return;
    const measure = () => {
      const width = Math.min(cardWidth, document.documentElement.clientWidth - 32);
      setCardSize((c) => (c.width === width && c.height === el.offsetHeight ? c : { width, height: el.offsetHeight }));
    };
    measure();
    const observer = new ResizeObserver(measure);
    observer.observe(el);
    return () => observer.disconnect();
  }, [mounted, cardWidth]);

  const open = () => {
    window.clearTimeout(timer.current);
    setPhase(reduced ? "open" : "opening");
  };

  const close = React.useCallback(
    (refocus = true) => {
      window.clearTimeout(timer.current);
      if (refocus) pillRef.current?.focus({ preventScroll: true });
      if (reduced) return setPhase("closed");
      setPhase("closing");
      timer.current = window.setTimeout(() => setPhase("closed"), DURATION);
    },
    [reduced]
  );

  // Paint the surface at the pill's size first, then let it grow.
  React.useEffect(() => {
    if (phase !== "opening") return;
    let inner = 0;
    const outer = requestAnimationFrame(() => (inner = requestAnimationFrame(() => setPhase("open"))));
    return () => {
      cancelAnimationFrame(outer);
      cancelAnimationFrame(inner);
    };
  }, [phase]);

  React.useEffect(() => () => window.clearTimeout(timer.current), []);

  React.useEffect(() => {
    if (!expanded) return;
    cardRef.current?.querySelector<HTMLElement>("a[href], button")?.focus({ preventScroll: true });
    const onKey = (e: KeyboardEvent) => e.key === "Escape" && close();
    const onDown = (e: PointerEvent) => !wrapRef.current?.contains(e.target as Node) && close(false);
    document.addEventListener("keydown", onKey);
    document.addEventListener("pointerdown", onDown);
    return () => {
      document.removeEventListener("keydown", onKey);
      document.removeEventListener("pointerdown", onDown);
    };
  }, [expanded, close]);

  const line = (
    <>
      <span className="flex h-6 shrink-0 items-center rounded-full bg-primary/10 px-2 text-[11.5px] font-medium text-primary">{badge}</span>
      <span className="whitespace-nowrap text-[13px] text-foreground">{children}</span>
      {more && (
        <span
          className="flex overflow-hidden whitespace-nowrap text-[13px] text-muted-foreground transition-[max-width,opacity] duration-500 group-hover/pill:opacity-100 group-focus-visible/pill:opacity-100 [max-width:0] group-hover/pill:[max-width:var(--more)] group-focus-visible/pill:[max-width:var(--more)] opacity-0"
          style={{ "--more": `${moreWidth}px`, transitionTimingFunction: EASE } as React.CSSProperties}
        >
          <span ref={moreRef} className="flex w-max shrink-0 items-center gap-1.5 pl-0.5">
            <span aria-hidden="true" className="text-border">
              ·
            </span>
            {more}
          </span>
        </span>
      )}
      <Arrow />
    </>
  );

  const pillClass = `group/pill inline-flex h-8 items-center gap-2 rounded-full bg-background pl-1 pr-3 shadow-[inset_0_0_0_1px_var(--border)] transition-[box-shadow] duration-300 hover:shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--foreground)_22%,transparent)] ${FOCUS}`;

  if (!card) {
    return (
      <div className={`inline-flex ${className}`} {...props}>
        <a ref={(el) => void (pillRef.current = el)} href={href} className={pillClass}>
          {line}
        </a>
      </div>
    );
  }

  return (
    <div ref={wrapRef} className={`relative inline-flex ${className}`} {...props}>
      <button
        ref={(el) => void (pillRef.current = el)}
        type="button"
        aria-expanded={expanded}
        aria-controls={mounted ? `${id}-card` : undefined}
        onClick={() => (expanded ? close() : open())}
        className={`${pillClass} ${mounted ? "opacity-0" : ""}`}
      >
        {line}
      </button>

      {mounted && (
        <div
          id={`${id}-card`}
          role="dialog"
          aria-label={typeof children === "string" ? children : "Announcement"}
          className="absolute left-1/2 top-0 z-50 overflow-hidden bg-popover text-left text-popover-foreground"
          style={{
            width: expanded ? cardSize.width : pill.width,
            height: expanded ? cardSize.height : pill.height,
            transform: "translateX(-50%)",
            borderRadius: expanded ? 22 : pill.height / 2,
            boxShadow: "inset 0 0 0 1px var(--border)",
            transition: reduced ? "none" : `width ${DURATION}ms ${EASE}, height ${DURATION}ms ${EASE}, border-radius ${DURATION}ms ${EASE}`,
          }}
        >
          {/* The pill's line, fading out as the card grows and back in as it folds. */}
          <span
            aria-hidden="true"
            className="pointer-events-none absolute left-1/2 top-0 flex h-8 -translate-x-1/2 items-center gap-2 pl-1 pr-3"
            style={{ opacity: expanded ? 0 : 1, transition: reduced ? "none" : expanded ? "opacity 120ms ease-out" : "opacity 200ms ease-out 180ms" }}
          >
            {line}
          </span>
          <div
            ref={cardRef}
            inert={!expanded}
            className="absolute left-0 top-0"
            style={{
              width: cardSize.width,
              opacity: expanded ? 1 : 0,
              transform: expanded ? "none" : "translateY(-6px)",
              transition: reduced ? "none" : expanded ? `opacity 260ms ease-out 140ms, transform ${DURATION}ms ${EASE}` : "opacity 100ms ease-out",
            }}
          >
            <button
              type="button"
              aria-label="Close"
              onClick={() => close()}
              className={`absolute right-2.5 top-2.5 z-10 flex size-7 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground ${FOCUS}`}
            >
              <svg aria-hidden="true" viewBox="0 0 12 12" className="size-3">
                <path d="M3 3l6 6M9 3l-6 6" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
              </svg>
            </button>
            {card}
          </div>
        </div>
      )}
    </div>
  );
}

Registry item: /r/announcement-pill.json