{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"announcement-pill","type":"registry:component","title":"Announcement pill","description":"\"New: …\" above your headline that opens into the news: the pill itself grows into a changelog card.","author":"Hairline UI","dependencies":[],"files":[{"path":"registry/announcement-pill.tsx","type":"registry:component","content":"\"use client\";\n\nimport * as React from \"react\";\n\n/* ─────────────────────────────────────────────────────────\n * ANNOUNCEMENT PILL: \"New: …\" that opens into the news\n *\n *   rest    a badge and one line, in a hairline pill\n *   hover   the arrow nudges on and a second phrase folds open\n *           (\"Read the changelog\")\n *   open    the pill itself grows into a card with the details:\n *           its size and corners ease from pill to card while\n *           the line cross-fades into the content\n *   close   the card folds back into the pill\n *\n * Without card content it's simply a link.\n * ───────────────────────────────────────────────────────── */\n\nexport interface AnnouncementPillProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"children\"> {\n  badge?: string;\n  children: React.ReactNode;\n  /** A second phrase that folds open on hover. */\n  more?: string;\n  /** Without card, the pill is a link to here. */\n  href?: string;\n  /** What the pill opens into. */\n  card?: React.ReactNode;\n  cardWidth?: number;\n}\n\ntype Phase = \"closed\" | \"opening\" | \"open\" | \"closing\";\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst DURATION = 420;\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background\";\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n\nfunction Arrow() {\n  return (\n    <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\">\n      <path d=\"M2.5 6h7M6.5 3l3 3-3 3\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.4\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n    </svg>\n  );\n}\n\nexport function AnnouncementPill({ badge = \"New\", children, more, href, card, cardWidth = 360, className = \"\", ...props }: AnnouncementPillProps) {\n  const reduced = useReducedMotion();\n  const id = React.useId();\n  const [phase, setPhase] = React.useState<Phase>(\"closed\");\n  const [pill, setPill] = React.useState({ width: 0, height: 32 });\n  const [cardSize, setCardSize] = React.useState({ width: cardWidth, height: 0 });\n  const [moreWidth, setMoreWidth] = React.useState(0);\n  const wrapRef = React.useRef<HTMLDivElement>(null);\n  const pillRef = React.useRef<HTMLElement | null>(null);\n  const cardRef = React.useRef<HTMLDivElement>(null);\n  const moreRef = React.useRef<HTMLSpanElement>(null);\n  const timer = React.useRef(0);\n\n  const expanded = phase === \"open\";\n  const mounted = phase !== \"closed\";\n\n  React.useLayoutEffect(() => {\n    const el = pillRef.current;\n    if (!el) return;\n    const measure = () => setPill((p) => (p.width === el.offsetWidth && p.height === el.offsetHeight ? p : { width: el.offsetWidth, height: el.offsetHeight }));\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, []);\n\n  React.useLayoutEffect(() => {\n    const el = moreRef.current;\n    if (el) setMoreWidth(el.offsetWidth);\n  }, [more]);\n\n  React.useLayoutEffect(() => {\n    const el = cardRef.current;\n    if (!el) return;\n    const measure = () => {\n      const width = Math.min(cardWidth, document.documentElement.clientWidth - 32);\n      setCardSize((c) => (c.width === width && c.height === el.offsetHeight ? c : { width, height: el.offsetHeight }));\n    };\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, [mounted, cardWidth]);\n\n  const open = () => {\n    window.clearTimeout(timer.current);\n    setPhase(reduced ? \"open\" : \"opening\");\n  };\n\n  const close = React.useCallback(\n    (refocus = true) => {\n      window.clearTimeout(timer.current);\n      if (refocus) pillRef.current?.focus({ preventScroll: true });\n      if (reduced) return setPhase(\"closed\");\n      setPhase(\"closing\");\n      timer.current = window.setTimeout(() => setPhase(\"closed\"), DURATION);\n    },\n    [reduced]\n  );\n\n  // Paint the surface at the pill's size first, then let it grow.\n  React.useEffect(() => {\n    if (phase !== \"opening\") return;\n    let inner = 0;\n    const outer = requestAnimationFrame(() => (inner = requestAnimationFrame(() => setPhase(\"open\"))));\n    return () => {\n      cancelAnimationFrame(outer);\n      cancelAnimationFrame(inner);\n    };\n  }, [phase]);\n\n  React.useEffect(() => () => window.clearTimeout(timer.current), []);\n\n  React.useEffect(() => {\n    if (!expanded) return;\n    cardRef.current?.querySelector<HTMLElement>(\"a[href], button\")?.focus({ preventScroll: true });\n    const onKey = (e: KeyboardEvent) => e.key === \"Escape\" && close();\n    const onDown = (e: PointerEvent) => !wrapRef.current?.contains(e.target as Node) && close(false);\n    document.addEventListener(\"keydown\", onKey);\n    document.addEventListener(\"pointerdown\", onDown);\n    return () => {\n      document.removeEventListener(\"keydown\", onKey);\n      document.removeEventListener(\"pointerdown\", onDown);\n    };\n  }, [expanded, close]);\n\n  const line = (\n    <>\n      <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>\n      <span className=\"whitespace-nowrap text-[13px] text-foreground\">{children}</span>\n      {more && (\n        <span\n          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\"\n          style={{ \"--more\": `${moreWidth}px`, transitionTimingFunction: EASE } as React.CSSProperties}\n        >\n          <span ref={moreRef} className=\"flex w-max shrink-0 items-center gap-1.5 pl-0.5\">\n            <span aria-hidden=\"true\" className=\"text-border\">\n              ·\n            </span>\n            {more}\n          </span>\n        </span>\n      )}\n      <Arrow />\n    </>\n  );\n\n  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}`;\n\n  if (!card) {\n    return (\n      <div className={`inline-flex ${className}`} {...props}>\n        <a ref={(el) => void (pillRef.current = el)} href={href} className={pillClass}>\n          {line}\n        </a>\n      </div>\n    );\n  }\n\n  return (\n    <div ref={wrapRef} className={`relative inline-flex ${className}`} {...props}>\n      <button\n        ref={(el) => void (pillRef.current = el)}\n        type=\"button\"\n        aria-expanded={expanded}\n        aria-controls={mounted ? `${id}-card` : undefined}\n        onClick={() => (expanded ? close() : open())}\n        className={`${pillClass} ${mounted ? \"opacity-0\" : \"\"}`}\n      >\n        {line}\n      </button>\n\n      {mounted && (\n        <div\n          id={`${id}-card`}\n          role=\"dialog\"\n          aria-label={typeof children === \"string\" ? children : \"Announcement\"}\n          className=\"absolute left-1/2 top-0 z-50 overflow-hidden bg-popover text-left text-popover-foreground\"\n          style={{\n            width: expanded ? cardSize.width : pill.width,\n            height: expanded ? cardSize.height : pill.height,\n            transform: \"translateX(-50%)\",\n            borderRadius: expanded ? 22 : pill.height / 2,\n            boxShadow: \"inset 0 0 0 1px var(--border)\",\n            transition: reduced ? \"none\" : `width ${DURATION}ms ${EASE}, height ${DURATION}ms ${EASE}, border-radius ${DURATION}ms ${EASE}`,\n          }}\n        >\n          {/* The pill's line, fading out as the card grows and back in as it folds. */}\n          <span\n            aria-hidden=\"true\"\n            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\"\n            style={{ opacity: expanded ? 0 : 1, transition: reduced ? \"none\" : expanded ? \"opacity 120ms ease-out\" : \"opacity 200ms ease-out 180ms\" }}\n          >\n            {line}\n          </span>\n          <div\n            ref={cardRef}\n            inert={!expanded}\n            className=\"absolute left-0 top-0\"\n            style={{\n              width: cardSize.width,\n              opacity: expanded ? 1 : 0,\n              transform: expanded ? \"none\" : \"translateY(-6px)\",\n              transition: reduced ? \"none\" : expanded ? `opacity 260ms ease-out 140ms, transform ${DURATION}ms ${EASE}` : \"opacity 100ms ease-out\",\n            }}\n          >\n            <button\n              type=\"button\"\n              aria-label=\"Close\"\n              onClick={() => close()}\n              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}`}\n            >\n              <svg aria-hidden=\"true\" viewBox=\"0 0 12 12\" className=\"size-3\">\n                <path d=\"M3 3l6 6M9 3l-6 6\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" />\n              </svg>\n            </button>\n            {card}\n          </div>\n        </div>\n      )}\n    </div>\n  );\n}\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/announcement-pill"}