# Release Pill

> The "New" badge above your headline, made useful. Hover it and the pill grows into a small changelog with your last few releases, then folds back into the pill when you leave.

An animated React component from KeyframeUI, installed as source with the shadcn CLI. Built with React, Tailwind CSS and Motion.

- Docs and live preview: https://keyframeui.com/components/release-pill
- Tags: Badge, Hover, Changelog
- npm dependencies: motion, clsx, tailwind-merge

## Installation

Register the @keyframeui registry in `components.json` once per project:

```json
{
  "registries": {
    "@keyframeui": "https://keyframeui.com/r/{name}.json"
  }
}
```

Then add the component. It lands in `components/ui/release-pill.tsx`, and the CLI also adds `lib/utils.ts` (the `cn` helper) and installs the npm dependencies:

```bash
npx shadcn@latest add @keyframeui/release-pill
```

Or install by URL, without registering the namespace:

```bash
npx shadcn@latest add https://keyframeui.com/r/release-pill.json
```

## Usage

The main demo shows typical usage.

### Hero Badge

An AI startup's hero with the pill above the headline. Hover it and the changelog opens over the headline; a couple of seconds after it scrolls into view it peeks at the second row once, and the version rolls from v2.3 to v2.4.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/release-pill-demo
```

```tsx title="components/release-pill-demo.tsx"
import { ReleasePill, type Release } from "@/components/ui/release-pill";

const releases: Release[] = [
  {
    version: "v2.4",
    title: "Agents 2.0",
    date: "Oct 10",
    href: "#agents-2",
    summary: "Agents that plan, act and check their own work.",
  },
  { version: "v2.3", title: "Slack approvals", date: "Sep 26", href: "#slack-approvals" },
  { version: "v2.2", title: "SOC 2 Type II", date: "Sep 12", href: "#soc-2" },
  { version: "v2.1", title: "Scheduled runs", date: "Aug 29", href: "#scheduled-runs" },
];

export function ReleasePillDemo() {
  return (
    <section className="relative flex w-full max-w-3xl flex-col items-center px-6 py-16 text-center text-foreground sm:py-24">
      <ReleasePill releases={releases} changelogHref="#changelog" peek />

      <h2 className="mt-7 max-w-[15ch] text-4xl/[1.05] font-semibold tracking-tight text-balance sm:text-6xl/[1.03]">
        The agent that does the busywork
      </h2>
      <p className="mt-5 max-w-md text-base/7 text-pretty text-muted-foreground">
        Hand it the weekly report, the expense chasing and the calendar tetris. It plans the steps,
        does them, and checks its own work before it tells you.
      </p>

      <div className="mt-9 flex flex-wrap items-center justify-center gap-3">
        <a
          href="#start"
          className="inline-flex h-11 items-center rounded-full bg-foreground px-6 text-sm font-medium text-background outline-none hover:opacity-90 focus-visible:ring-2 focus-visible:ring-foreground focus-visible:ring-offset-2 focus-visible:ring-offset-background"
        >
          Start for free
        </a>
        <a
          href="#demo"
          className="inline-flex h-11 items-center rounded-full px-6 text-sm font-medium ring-1 ring-foreground/15 outline-none hover:bg-foreground/5 focus-visible:ring-2 focus-visible:ring-foreground"
        >
          Watch a demo
        </a>
      </div>
    </section>
  );
}
```

## Examples

Finished sections built with Release Pill. Each one installs whole, with the component included.

### Launch Week

The closed pill cycles through the week's announcements. Opened, it lists the days and ends on a dimmed, locked Day 4 row with a live countdown.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/release-pill-launch-demo
```

```tsx title="components/release-pill-launch-demo.tsx"
"use client";

import * as React from "react";

import { ReleasePill, type Release } from "@/components/ui/release-pill";

const releases: Release[] = [
  {
    version: "Day 3",
    title: "Usage-based pricing",
    date: "Wed",
    href: "#day-3",
    summary: "Pay for the runs you make. Nothing else, no seats.",
  },
  { version: "Day 2", title: "Slack approvals", date: "Tue", href: "#day-2" },
  { version: "Day 1", title: "Agents 2.0", date: "Mon", href: "#day-1" },
];

// Day 4 unlocks 14h 32m after the demo loads.
const UNLOCK_SECONDS = 14 * 3600 + 32 * 60;

function Countdown() {
  const [left, setLeft] = React.useState(UNLOCK_SECONDS);
  React.useEffect(() => {
    const timer = window.setInterval(() => setLeft((s) => Math.max(0, s - 1)), 1000);
    return () => window.clearInterval(timer);
  }, []);
  const minutes = Math.ceil(left / 60);
  return (
    <span className="tabular-nums">
      in {Math.floor(minutes / 60)}h {String(minutes % 60).padStart(2, "0")}m
    </span>
  );
}

function LockedDay() {
  return (
    <div aria-disabled="true" className="flex items-center gap-3 text-muted-foreground opacity-60 select-none">
      <svg
        aria-hidden
        viewBox="0 0 16 16"
        className="size-3.5 shrink-0"
        fill="none"
        stroke="currentColor"
        strokeWidth="1.5"
        strokeLinecap="round"
        strokeLinejoin="round"
      >
        <rect x="3" y="7" width="10" height="7" rx="2" />
        <path d="M5.5 7V5a2.5 2.5 0 0 1 5 0v2" />
      </svg>
      <span className="sr-only">Locked.</span>
      <span className="font-medium">Day 4 · Tomorrow</span>
      <span className="ml-auto text-xs">
        <Countdown />
      </span>
    </div>
  );
}

export function ReleasePillLaunchDemo() {
  return (
    <section className="relative flex w-full max-w-3xl flex-col items-center px-6 py-16 text-center text-foreground sm:py-24">
      <ReleasePill
        releases={releases}
        tag="Launch week"
        changelogHref="#launch-week"
        cycle
      >
        <LockedDay />
      </ReleasePill>

      <h2 className="mt-7 max-w-[14ch] text-4xl/[1.05] font-semibold tracking-tight text-balance sm:text-6xl/[1.03]">
        One new thing, every morning
      </h2>
      <p className="mt-5 max-w-md text-base/7 text-pretty text-muted-foreground">
        Launch week is here. Three days in, three things shipped, and the best one is still to
        come.
      </p>

      <a
        href="#remind"
        className="mt-9 inline-flex h-11 items-center rounded-full bg-foreground px-6 text-sm font-medium text-background outline-none hover:opacity-90 focus-visible:ring-2 focus-visible:ring-foreground focus-visible:ring-offset-2 focus-visible:ring-offset-background"
      >
        Remind me tomorrow
      </a>
    </section>
  );
}
```

### Version Bump

A dark developer-tool hero: monospace versions with a Feature, Fix or Breaking tag on each row, and a last row with the upgrade command whose copy button turns into Copied.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/release-pill-devtool-demo
```

```tsx title="components/release-pill-devtool-demo.tsx"
"use client";

import * as React from "react";

import { ReleasePill, type Release } from "@/components/ui/release-pill";

const KIND = {
  Feature: "bg-emerald-500/15 text-emerald-300",
  Fix: "bg-amber-500/15 text-amber-300",
  Breaking: "bg-rose-500/15 text-rose-300",
};

function Kind({ kind }: { kind: keyof typeof KIND }) {
  return (
    <span
      className={`shrink-0 rounded px-1.5 py-px font-mono text-[10px] leading-4 font-medium ${KIND[kind]}`}
    >
      {kind}
    </span>
  );
}

const releases: Release[] = [
  {
    version: "v3.2.0",
    title: "Parallel task graph",
    date: "Oct 10",
    href: "#v3.2.0",
    label: <Kind kind="Feature" />,
    summary: "Independent tasks run side by side. 2.4× faster on 8 cores.",
  },
  {
    version: "v3.1.4",
    title: "Fix watch-mode leak",
    date: "Oct 3",
    href: "#v3.1.4",
    label: <Kind kind="Fix" />,
  },
  {
    version: "v3.0.0",
    title: "ESM-only output",
    date: "Sep 19",
    href: "#v3.0.0",
    label: <Kind kind="Breaking" />,
  },
];

const COMMAND = "npm i acme@3";

function UpgradeCommand() {
  const [copied, setCopied] = React.useState(false);
  const timer = React.useRef(0);
  React.useEffect(() => () => window.clearTimeout(timer.current), []);

  const copy = () => {
    navigator.clipboard?.writeText(COMMAND).catch(() => {});
    setCopied(true);
    window.clearTimeout(timer.current);
    timer.current = window.setTimeout(() => setCopied(false), 1800);
  };

  return (
    <div className="flex items-center gap-2 font-mono text-xs">
      <span aria-hidden className="text-muted-foreground select-none">
        $
      </span>
      <code className="min-w-0 flex-1 truncate">{COMMAND}</code>
      <button
        type="button"
        onClick={copy}
        aria-label={copied ? "Copied" : `Copy ${COMMAND}`}
        className="relative inline-flex h-7 w-[4.5rem] shrink-0 cursor-pointer items-center justify-center rounded-md bg-foreground/10 text-[11px] font-medium outline-none hover:bg-foreground/15 focus-visible:ring-2 focus-visible:ring-foreground/60"
      >
        <span
          className={`absolute transition-all duration-200 ${copied ? "-translate-y-1.5 opacity-0" : "opacity-100"}`}
        >
          Copy
        </span>
        <span
          aria-hidden={!copied}
          className={`absolute inline-flex items-center gap-1 text-emerald-300 transition-all duration-200 ${copied ? "opacity-100" : "translate-y-1.5 opacity-0"}`}
        >
          <svg
            aria-hidden
            viewBox="0 0 16 16"
            className="size-3"
            fill="none"
            stroke="currentColor"
            strokeWidth="2"
            strokeLinecap="round"
            strokeLinejoin="round"
          >
            <path d="m3.5 8.5 3 3 6-7" />
          </svg>
          Copied
        </span>
      </button>
    </div>
  );
}

export function ReleasePillDevtoolDemo() {
  return (
    // The panel is always dark, so it re-points the theme colors the pill reads.
    <section className="relative w-full max-w-3xl rounded-3xl bg-background px-6 py-16 text-center text-foreground ring-1 ring-white/10 [--background:#0b0b0e] [--foreground:#f4f4f5] [--muted-foreground:#9a9aa6] sm:py-24">
      <div className="flex flex-col items-center">
        <ReleasePill releases={releases} tag="Release" changelogHref="#changelog">
          <UpgradeCommand />
        </ReleasePill>

        <h2 className="mt-7 max-w-[16ch] font-mono text-3xl/[1.15] font-semibold tracking-tight text-balance sm:text-5xl/[1.1]">
          Builds that finish before you look away
        </h2>
        <p className="mt-5 max-w-md text-base/7 text-pretty text-muted-foreground">
          Acme runs your tasks as a graph, caches what hasn&apos;t changed, and tells you why when
          something does.
        </p>

        <div className="mt-9 flex flex-wrap items-center justify-center gap-3">
          <a
            href="#docs"
            className="inline-flex h-11 items-center rounded-lg bg-foreground px-5 text-sm font-medium text-background outline-none hover:opacity-90 focus-visible:ring-2 focus-visible:ring-foreground focus-visible:ring-offset-2 focus-visible:ring-offset-background"
          >
            Read the docs
          </a>
          <a
            href="#github"
            className="inline-flex h-11 items-center rounded-lg px-5 font-mono text-sm ring-1 ring-foreground/15 outline-none hover:bg-foreground/5 focus-visible:ring-2 focus-visible:ring-foreground"
          >
            github.com/acme
          </a>
        </div>
      </div>
    </section>
  );
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `releases` (required) | `Release[]` |  | The releases, newest first; the first is the latest. Each one is `{ version, title, date?, href?, summary?, label? }`. `version` is shown as written ("v2.4", "Day 3") and its digits roll from the previous release's on first view. `summary` is the line under the latest title, and `label` is a small node, like a Feature tag, shown with the release in the open card. |
| `tag` | `string` | `"New"` | The small tag at the start of the pill. |
| `changelogHref` | `string` |  | Adds a "View changelog →" link at the bottom of the open card. |
| `peek` | `boolean` | `false` | About 2s after the pill first scrolls into view, open just far enough to show the second row, then close. Once per page view; a pointer cancels it. Skipped with reduced motion. |
| `cycle` | `boolean \| number` | `false` | Step the closed pill through the latest releases every 4s, each title sliding up as the next arrives. A number sets how many releases; `true` is 3. A pointer or focus stops it, and opening brings the latest back. |
| `align` | `"center" \| "start"` | `"center"` | Where the card grows from: out of the pill's middle (above a centered headline) or out of its left edge. |
| `children` | `React.ReactNode` |  | An extra row at the end of the list, after the releases: an upgrade command, a locked next release. |
| `className` | `string` |  | Extra classes for the wrapper. Set the open card's width with `[--release-pill-width:26rem]` (default `22rem`). |

## Source

```tsx title="components/ui/release-pill.tsx"
"use client";

import * as React from "react";
import {
  AnimatePresence,
  animate,
  motion,
  useInView,
  useMotionValue,
  useReducedMotion,
  useTransform,
} from "motion/react";

import { cn } from "@/lib/utils";

/* ─────────────────────────────────────────────────────────
 * RELEASE PILL STORYBOARD
 *
 *  rest     a pill: a small tag, the latest release's title, an
 *           arrow and a dot that breathes
 *  view     the first time it scrolls into view the version rolls
 *           digit by digit, from the previous release to the latest
 *           (v2.3 → v2.4), the rightmost digit first
 *  intent   the pointer rests on it for ~120ms and it opens; sweeping
 *           across does nothing. Leaving waits ~250ms before it folds
 *  grow     one shape on one spring (~0.35s, the smallest overshoot):
 *           width, height and corners ease from the pill to a card
 *           that floats over what's below, so nothing is pushed down.
 *           The header row stays put: its arrow and dot give way to
 *           the date, and the summary opens under it
 *  rows     the rows slide out from under the one above, 40ms apart,
 *           each rising from 0.96 scale. Closing tucks them back in
 *           reverse, last row first, as the card shrinks to the pill
 *  peek     with peek, ~2s after it first shows it opens just far
 *           enough to reveal the second row, holds, and closes. Once
 *           per page view, and a pointer cancels it
 *  cycle    with cycle, the closed pill steps through the latest few
 *           releases every 4s: the title slides up as the next one
 *           arrives and the pill's width follows. A pointer stops it,
 *           and opening brings the latest back
 *  click    a click on the header toggles it; a click on a pill the
 *           pointer already opened pins it, so it stays open when the
 *           pointer leaves. A tap toggles
 *  keys     Enter and Space toggle, Tab walks the rows, Esc closes and
 *           returns focus to the pill, and tabbing out folds it
 *  reduced  no growing, sliding or rolling: the pill resizes at once
 *           and the rows fade in and out
 * ───────────────────────────────────────────────────────── */

const PILL = {
  height: 36, // px; the closed pill
  radius: 16, // px; the open card's corners (a 36px pill starts fully round, at 18)
  grow: { type: "spring", visualDuration: 0.35, bounce: 0.08 },
  fold: { type: "spring", visualDuration: 0.3, bounce: 0 },
  row: { type: "spring", visualDuration: 0.32, bounce: 0.06 },
  slot: { type: "spring", visualDuration: 0.4, bounce: 0 }, // the title's slot following a cycle
  slide: { duration: 0.4, ease: [0.22, 1, 0.36, 1] }, // a cycled title sliding up
  roll: { type: "spring", visualDuration: 0.7, bounce: 0.05 }, // a digit's wheel
  fade: { duration: 0.15, ease: "easeOut" },
  breathe: 3.2, // s per breath of the dot
  intent: 120, // ms the pointer rests on the pill before it opens
  leave: 250, // ms outside before it folds
  stagger: 0.04, // s between rows as they slide out
  tuck: 0.025, // s between rows as they tuck back in, last first
  lead: 0.05, // s the shape leads the first row by
  rise: 10, // px a hidden row sits above its place
  scale: 0.96, // scale a hidden row starts from
  cycle: 4000, // ms each release stays in a cycling pill
  cycleCount: 3, // releases a cycling pill steps through
  peekAfter: 2000, // ms after it first shows before a peek
  peekHold: 1100, // ms a peek stays open
  peekMargin: 8, // px below the second row a peek shows
  rollDelay: 0.3, // s after it shows before the version rolls
  rollStagger: 0.07, // s between neighboring digits
} as const;

type Phase = "closed" | "peek" | "open";

export interface Release {
  /** Shown in monospace before the title, like "v2.4" or "Day 3". Digits roll from the previous release's version. */
  version: string;
  title: string;
  /** Shown at the end of the row, like "Oct 10". */
  date?: string;
  /** Where the release's row links to. */
  href?: string;
  /** One line under the latest release's title when the pill is open. */
  summary?: string;
  /** Anything small shown with the release in the open card: a Feature or Fix tag. */
  label?: React.ReactNode;
}

export interface ReleasePillProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
  /** The releases, newest first. The first one is the latest. */
  releases: Release[];
  /** The small tag at the start of the pill. */
  tag?: string;
  /** Adds a "View changelog →" link at the bottom of the open card. */
  changelogHref?: string;
  /** Open just far enough to show the second row, once, about 2s after the pill first shows. */
  peek?: boolean;
  /** Step the closed pill through the latest releases every 4s. A number sets how many; true is 3. */
  cycle?: boolean | number;
  /** Where the card grows from: out of the pill's middle, or out of its left edge. */
  align?: "center" | "start";
  /** An extra row at the end of the list, after the releases. */
  children?: React.ReactNode;
}

const isDigit = (char: string) => char.length === 1 && char >= "0" && char <= "9";

/* ── VersionRoll ────────────────────────────────────────── */

// Each changing character sits in a one-character window on a short strip. A digit's strip
// counts up to its new value (3 → 4, or 4 → 0 the long way round); anything else swaps.
function VersionRoll({
  from,
  to,
  go,
  reduced,
}: {
  from: string;
  to: string;
  go: boolean;
  reduced: boolean;
}) {
  const length = Math.max(from.length, to.length);
  const at = (text: string, i: number) => text[i - (length - text.length)] ?? "";
  const cells = Array.from({ length }, (_, i) => ({ prev: at(from, i), next: at(to, i) }));
  const delays: number[] = [];
  let order = 0;
  for (let i = length - 1; i >= 0; i--) {
    if (cells[i].prev !== cells[i].next) delays[i] = PILL.rollDelay + order++ * PILL.rollStagger;
  }
  const width = (char: string) => (char ? "1ch" : "0ch");

  return (
    <>
      <span className="sr-only">{to}</span>
      <span aria-hidden className="inline-flex">
        {cells.map(({ prev, next }, i) => {
          if (prev === next) return <span key={i} className="whitespace-pre">{next}</span>;
          const strip =
            isDigit(prev) && isDigit(next)
              ? Array.from({ length: ((Number(next) - Number(prev) + 10) % 10) + 1 }, (_, k) =>
                  String((Number(prev) + k) % 10),
                )
              : [prev, next];
          const transition = reduced ? { duration: 0 } : { ...PILL.roll, delay: delays[i] };
          return (
            <motion.span
              key={i}
              className="relative inline-block h-5 overflow-hidden"
              initial={false}
              animate={{ width: go ? width(next) : width(prev) }}
              transition={transition}
            >
              <motion.span
                className="flex flex-col"
                initial={false}
                animate={{ y: go ? `${(-100 * (strip.length - 1)) / strip.length}%` : "0%" }}
                transition={transition}
              >
                {strip.map((char, k) => (
                  <span key={k} className="block h-5 text-center whitespace-pre">
                    {char}
                  </span>
                ))}
              </motion.span>
            </motion.span>
          );
        })}
      </span>
    </>
  );
}

/* ── Row ────────────────────────────────────────────────── */

// One row of the list. It slides out from under the row above (which sits higher in the
// stack), and tucks back in last row first.
function Row({
  index,
  count,
  phase,
  reduced,
  className,
  children,
}: {
  index: number;
  count: number;
  phase: Phase;
  reduced: boolean;
  className?: string;
  children: React.ReactNode;
}) {
  const shown = phase === "open" || (phase === "peek" && index < 2);
  const delay = shown ? PILL.lead + index * PILL.stagger : (count - 1 - index) * PILL.tuck;
  // Reduced motion fades; the scale and rise wait until the row has faded out.
  const transition = reduced
    ? { default: { duration: 0, delay: shown ? 0 : PILL.fade.duration }, opacity: PILL.fade }
    : {
        default: { ...(shown ? PILL.row : PILL.fold), delay },
        opacity: { duration: 0.2, delay },
      };
  return (
    <motion.li
      className={cn("relative bg-background", className)}
      style={{ zIndex: count - index, transformOrigin: "50% 0%" }}
      initial={false}
      animate={
        shown
          ? { opacity: 1, scale: 1, y: 0 }
          : { opacity: 0, scale: PILL.scale, y: -PILL.rise }
      }
      transition={transition}
    >
      {children}
    </motion.li>
  );
}

/* ── Face ───────────────────────────────────────────────── */

// The closed pill's contents: tag, a slot for the release, arrow, dot. It's drawn twice, once
// invisibly in the flow to give the pill its size, and once in the shape.
function Face({
  tag,
  slotRef,
  slotStyle,
  trailingStyle,
  reduced,
  children,
}: {
  tag: string;
  slotRef?: React.Ref<HTMLSpanElement>;
  slotStyle?: React.ComponentProps<typeof motion.span>["style"];
  trailingStyle?: React.ComponentProps<typeof motion.span>["style"];
  reduced: boolean;
  children: React.ReactNode;
}) {
  return (
    <span className="flex h-9 w-max shrink-0 items-center gap-2 pr-3.5 pl-1.5 text-[13px] whitespace-nowrap">
      <span className="inline-flex h-6 items-center rounded-full bg-foreground px-2.5 text-[11px] font-semibold tracking-wide text-background">
        {tag}
      </span>
      <motion.span
        ref={slotRef}
        className="grid h-5 overflow-hidden text-left leading-5"
        style={slotStyle}
      >
        {children}
      </motion.span>
      <motion.span className="flex items-center gap-2" style={trailingStyle}>
        <span aria-hidden className="text-muted-foreground">
          →
        </span>
        <span aria-hidden className="relative flex size-2 items-center justify-center">
          <motion.span
            className="absolute inset-0 rounded-full bg-emerald-500"
            animate={{ opacity: [1, 0.35, 1], scale: [1, 0.7, 1] }}
            transition={
              reduced
                ? { duration: 0 }
                : { duration: PILL.breathe, ease: "easeInOut", repeat: Infinity }
            }
          />
        </span>
      </motion.span>
    </span>
  );
}

/* ── ReleasePill ────────────────────────────────────────── */

/**
 * The "New" badge above a headline, made useful. Hover it and the pill grows into a small
 * changelog with the latest releases; leave and it folds back into the pill.
 */
export function ReleasePill({
  releases,
  tag = "New",
  changelogHref,
  peek = false,
  cycle = false,
  align = "center",
  className,
  children,
  onPointerEnter,
  onPointerLeave,
  onPointerDown,
  onFocus,
  onBlur,
  onKeyDown,
  ...props
}: ReleasePillProps) {
  const H: number = PILL.height;
  const reduced = useReducedMotion() ?? false;
  const id = React.useId();
  const listId = `${id}-releases`;

  const latest = releases[0];
  const previous = releases.slice(1);
  const cycleCount = cycle
    ? Math.min(releases.length, typeof cycle === "number" ? Math.max(1, cycle) : PILL.cycleCount)
    : 1;

  const [phase, setPhase] = React.useState<Phase>("closed");
  const [index, setIndex] = React.useState(0);
  const [inside, setInside] = React.useState(false);
  const [focused, setFocused] = React.useState(false);

  const rootRef = React.useRef<HTMLDivElement>(null);
  const sizerRef = React.useRef<HTMLSpanElement>(null);
  const ghostSlotRef = React.useRef<HTMLSpanElement>(null);
  const listRef = React.useRef<HTMLUListElement>(null);
  const triggerRef = React.useRef<HTMLButtonElement>(null);

  const inView = useInView(rootRef, { once: true, amount: 0.6 });

  // Everything that moves is a motion value, so a frame costs no React render.
  const grow = useMotionValue(0); // 0 pill, 1 card: width, corners, the look
  const reveal = useMotionValue(0); // 0 pill, 1 card: height
  const lineWidth = useMotionValue(0); // the width of the release now in the pill
  const width = useMotionValue<number | string>("max-content");
  const slotWidth = useMotionValue<number | string>("auto");
  const height = useMotionValue(H);
  const radius = useMotionValue(H / 2);
  const trailing = useTransform(grow, [0, 0.35], [1, 0]);
  const detail = useTransform(grow, [0.4, 0.9], [0, 1]);
  const tint = useTransform(grow, [0, 1], [1, 0]);

  const sizes = React.useRef({
    ready: false,
    chrome: 0,
    lines: [] as number[],
    cardW: 0,
    cardH: H,
    peekH: H,
  });
  const live = React.useRef({
    inside: false,
    focus: false,
    pinned: false,
    kind: "mouse",
    intent: 0,
    leave: 0,
    peek: 0,
    peeked: false,
  });
  const latestState = React.useRef({ phase, index });
  React.useLayoutEffect(() => {
    latestState.current = { phase, index };
  });

  // The shape's size from its three progress values.
  const sync = React.useCallback(() => {
    const s = sizes.current;
    if (!s.ready) return;
    const g = grow.get();
    const closed = s.chrome + lineWidth.get();
    slotWidth.set(lineWidth.get());
    width.set(closed + (s.cardW - closed) * g);
    height.set(H + (s.cardH - H) * reveal.get());
    radius.set(Math.max(0, H / 2 + (PILL.radius - H / 2) * g));
  }, [H, grow, reveal, lineWidth, slotWidth, width, height, radius]);

  const measure = React.useCallback(() => {
    const sizer = sizerRef.current;
    const slot = ghostSlotRef.current;
    const list = listRef.current;
    if (!sizer || !slot || !list) return;
    const s = sizes.current;
    s.chrome = sizer.getBoundingClientRect().width - slot.getBoundingClientRect().width;
    s.lines = Array.from(slot.children, (line) => line.getBoundingClientRect().width);
    s.cardW = list.offsetWidth;
    s.cardH = H + list.offsetHeight;
    const second = list.children[Math.min(1, list.children.length - 1)] as HTMLElement | undefined;
    s.peekH = second
      ? Math.min(s.cardH, H + second.offsetTop + second.offsetHeight + PILL.peekMargin)
      : s.cardH;
    s.ready = true;
    lineWidth.set(s.lines[latestState.current.index] ?? s.lines[0] ?? 0);
    sync();
  }, [H, lineWidth, sync]);

  React.useLayoutEffect(() => {
    measure();
    const observer = new ResizeObserver(measure);
    if (sizerRef.current) observer.observe(sizerRef.current);
    if (listRef.current) observer.observe(listRef.current);
    return () => observer.disconnect();
  }, [measure]);

  React.useEffect(() => {
    const stops = [grow, reveal, lineWidth].map((value) => value.on("change", sync));
    return () => stops.forEach((stop) => stop());
  }, [grow, reveal, lineWidth, sync]);

  /* Open, peek, close. */
  React.useEffect(() => {
    const s = sizes.current;
    const open = phase !== "closed";
    const target =
      phase === "open"
        ? 1
        : phase === "peek"
          ? Math.min(1, (s.peekH - H) / Math.max(1, s.cardH - H))
          : 0;
    if (reduced) {
      grow.jump(open ? 1 : 0);
      reveal.jump(target);
      return;
    }
    const controls = [
      animate(grow, open ? 1 : 0, open ? PILL.grow : { ...PILL.fold, delay: 0.04 }),
      animate(reveal, target, open ? PILL.grow : PILL.fold),
    ];
    return () => controls.forEach((control) => control.stop());
  }, [phase, reduced, H, grow, reveal]);

  /* The pill's width follows the release now showing. */
  React.useEffect(() => {
    const s = sizes.current;
    const w = s.lines[index];
    if (!s.ready || w === undefined) return;
    if (reduced) {
      lineWidth.jump(w);
      return;
    }
    const control = animate(lineWidth, w, PILL.slot);
    return () => control.stop();
  }, [index, reduced, lineWidth]);

  const clearTimers = React.useCallback(() => {
    const L = live.current;
    window.clearTimeout(L.intent);
    window.clearTimeout(L.leave);
    window.clearTimeout(L.peek);
  }, []);
  React.useEffect(() => clearTimers, [clearTimers]);

  const openNow = React.useCallback(() => {
    clearTimers();
    live.current.peeked = true;
    setIndex(0); // a cycling pill returns to the latest release as it opens
    setPhase("open");
  }, [clearTimers]);

  const closeNow = React.useCallback(() => {
    clearTimers();
    live.current.pinned = false;
    setPhase("closed");
  }, [clearTimers]);

  const scheduleClose = React.useCallback(() => {
    const L = live.current;
    window.clearTimeout(L.leave);
    L.leave = window.setTimeout(() => {
      if (!L.inside && !L.focus) closeNow();
    }, PILL.leave);
  }, [closeNow]);

  /* Pointer: hover intent for mice. Taps and clicks go through the header. */
  const handlePointerEnter = (event: React.PointerEvent<HTMLDivElement>) => {
    onPointerEnter?.(event);
    if (event.pointerType !== "mouse") return;
    const L = live.current;
    L.inside = true;
    L.peeked = true; // a pointer has found it; the hint is no longer needed
    setInside(true);
    window.clearTimeout(L.leave);
    window.clearTimeout(L.peek);
    if (latestState.current.phase !== "open") {
      window.clearTimeout(L.intent);
      L.intent = window.setTimeout(openNow, PILL.intent);
    }
  };

  const handlePointerLeave = (event: React.PointerEvent<HTMLDivElement>) => {
    onPointerLeave?.(event);
    if (event.pointerType !== "mouse") return;
    const L = live.current;
    L.inside = false;
    setInside(false);
    window.clearTimeout(L.intent);
    if (latestState.current.phase === "open" && !L.pinned) scheduleClose();
  };

  const onTriggerClick = (event: React.MouseEvent) => {
    const L = live.current;
    const byMouse = event.detail > 0 && L.kind === "mouse";
    if (latestState.current.phase !== "open") {
      openNow();
      L.pinned = true;
    } else if (byMouse && !L.pinned) {
      L.pinned = true; // the pointer opened it; a click keeps it open
    } else {
      closeNow();
    }
  };

  // A click or tap outside folds it, pinned or not.
  React.useEffect(() => {
    if (phase !== "open") return;
    const onDown = (event: PointerEvent) => {
      if (!rootRef.current?.contains(event.target as Node)) closeNow();
    };
    document.addEventListener("pointerdown", onDown);
    return () => document.removeEventListener("pointerdown", onDown);
  }, [phase, closeNow]);

  /* Peek: once, a couple of seconds after it first shows. */
  React.useEffect(() => {
    if (!peek || !inView || reduced) return;
    const L = live.current;
    if (L.peeked) return;
    const start = window.setTimeout(() => {
      if (L.peeked || latestState.current.phase !== "closed" || L.inside || L.focus) return;
      L.peeked = true;
      setPhase("peek");
      L.peek = window.setTimeout(
        () => setPhase((current) => (current === "peek" ? "closed" : current)),
        PILL.peekHold,
      );
    }, PILL.peekAfter);
    return () => window.clearTimeout(start);
  }, [peek, inView, reduced]);

  /* Cycle: step through the latest releases until a pointer or focus arrives. */
  const engaged = inside || focused || phase !== "closed";
  React.useEffect(() => {
    if (cycleCount < 2 || !inView || engaged) return;
    const timer = window.setTimeout(() => setIndex((i) => (i + 1) % cycleCount), PILL.cycle);
    return () => window.clearTimeout(timer);
  }, [cycleCount, inView, engaged, index]);

  if (!latest) return null;

  const current = releases[Math.min(index, releases.length - 1)];
  const rollFrom = previous[0]?.version;
  const rolling = index === 0 && rollFrom !== undefined && rollFrom !== latest.version;
  const open = phase === "open";
  const rowCount = 1 + previous.length + (children ? 1 : 0) + (changelogHref ? 1 : 0);

  // A release in the pill. Only the live copy of the latest release rolls its version.
  const line = (release: Release, roll = false) => (
    <span className="flex items-center gap-1.5">
      <span className="font-mono text-xs text-muted-foreground tabular-nums">
        {roll && release === latest && rolling ? (
          <VersionRoll from={rollFrom} to={latest.version} go={inView} reduced={reduced} />
        ) : (
          release.version
        )}
      </span>
      <span className="font-medium">{release.title}</span>
    </span>
  );

  const pillAnchor = align === "center" ? "left-1/2 -translate-x-1/2" : "left-0";
  const shapeStyle = { width, height, borderRadius: radius };

  return (
    <div
      {...props}
      ref={rootRef}
      data-state={phase}
      className={cn("pointer-events-none relative z-10 inline-flex h-9 align-middle", className)}
      onPointerEnter={handlePointerEnter}
      onPointerLeave={handlePointerLeave}
      onPointerDown={(event) => {
        onPointerDown?.(event);
        live.current.kind = event.pointerType;
      }}
      onFocus={(event) => {
        onFocus?.(event);
        if (event.target.matches(":focus-visible")) {
          live.current.focus = true;
          setFocused(true);
        }
      }}
      onBlur={(event) => {
        onBlur?.(event);
        if (event.currentTarget.contains(event.relatedTarget as Node | null)) return;
        live.current.focus = false;
        setFocused(false);
        if (latestState.current.phase === "open") scheduleClose();
      }}
      onKeyDown={(event) => {
        onKeyDown?.(event);
        if (event.key !== "Escape" || latestState.current.phase === "closed") return;
        event.preventDefault();
        closeNow();
        triggerRef.current?.focus();
      }}
    >
      {/* Gives the pill its place in the page. The widest release sets its width, so a cycling pill never reflows what's beside it. */}
      <span ref={sizerRef} aria-hidden className="invisible inline-flex">
        <Face tag={tag} slotRef={ghostSlotRef} reduced>
          {releases.slice(0, cycleCount).map((release, i) => (
            <span key={i} className="col-start-1 row-start-1 w-max justify-self-start">
              {line(release)}
            </span>
          ))}
        </Face>
      </span>

      <motion.div
        aria-hidden
        className={cn(
          "absolute top-0 shadow-[0_18px_44px_-14px_rgb(0_0_0/0.28),0_4px_14px_-6px_rgb(0_0_0/0.14)] dark:shadow-[0_18px_44px_-14px_rgb(0_0_0/0.8)]",
          pillAnchor,
        )}
        style={{ ...shapeStyle, opacity: grow }}
      />

      <motion.div
        className={cn(
          "pointer-events-auto absolute top-0 overflow-hidden bg-background text-foreground ring-1 ring-foreground/10",
          "has-[[data-release-pill-trigger]:focus-visible]:outline-2 has-[[data-release-pill-trigger]:focus-visible]:outline-offset-2 has-[[data-release-pill-trigger]:focus-visible]:outline-foreground/70",
          pillAnchor,
        )}
        style={shapeStyle}
      >
        <motion.div
          aria-hidden
          className="pointer-events-none absolute inset-x-0 top-0 h-full bg-foreground/[0.045]"
          style={{ opacity: tint }}
        />

        <button
          ref={triggerRef}
          type="button"
          data-release-pill-trigger=""
          aria-expanded={open}
          aria-controls={listId}
          onClick={onTriggerClick}
          className="relative flex h-9 w-full cursor-pointer items-center text-left outline-none [-webkit-tap-highlight-color:transparent]"
        >
          <Face
            tag={tag}
            slotStyle={{ width: slotWidth }}
            trailingStyle={{ opacity: trailing }}
            reduced={reduced}
          >
            <AnimatePresence initial={false}>
              <motion.span
                key={index}
                className="col-start-1 row-start-1 w-max justify-self-start"
                initial={reduced ? { opacity: 0 } : { y: "100%", opacity: 0 }}
                animate={{ y: 0, opacity: 1 }}
                exit={reduced ? { opacity: 0 } : { y: "-100%", opacity: 0 }}
                transition={reduced ? PILL.fade : PILL.slide}
              >
                {line(current, true)}
              </motion.span>
            </AnimatePresence>
          </Face>
          {latest.date ? (
            <motion.span
              aria-hidden={!open}
              className="absolute right-3.5 text-xs text-muted-foreground tabular-nums"
              style={{ opacity: detail }}
            >
              {latest.date}
            </motion.span>
          ) : null}
        </button>

        <ul
          ref={listRef}
          id={listId}
          inert={!open}
          className="absolute top-9 left-0 w-[min(var(--release-pill-width,22rem),calc(100vw-2rem))] text-left text-[13px]"
        >
          <Row index={0} count={rowCount} phase={phase} reduced={reduced}>
            <a
              href={latest.href ?? "#"}
              className="group/row block px-3.5 pb-3 leading-5 text-muted-foreground outline-none hover:text-foreground focus-visible:bg-foreground/[0.06] focus-visible:text-foreground"
            >
              {latest.label ? <span className="mr-2 align-middle">{latest.label}</span> : null}
              {latest.summary ?? "Read the release notes"}
              <span
                aria-hidden
                className="ml-1 inline-block transition-transform duration-200 group-hover/row:translate-x-0.5"
              >
                →
              </span>
            </a>
          </Row>

          {previous.map((release, i) => (
            <Row
              key={i}
              index={i + 1}
              count={rowCount}
              phase={phase}
              reduced={reduced}
              className={i === 0 ? "border-t border-foreground/10" : undefined}
            >
              <a
                href={release.href ?? "#"}
                className="flex items-baseline gap-3 px-3.5 py-2 outline-none hover:bg-foreground/[0.05] focus-visible:bg-foreground/[0.06]"
              >
                <span className="w-12 shrink-0 font-mono text-xs text-muted-foreground tabular-nums">
                  {release.version}
                </span>
                <span className="min-w-0 flex-1 truncate font-medium">{release.title}</span>
                {release.label}
                {release.date ? (
                  <span className="shrink-0 text-xs text-muted-foreground tabular-nums">
                    {release.date}
                  </span>
                ) : null}
              </a>
            </Row>
          ))}

          {children ? (
            <Row
              index={1 + previous.length}
              count={rowCount}
              phase={phase}
              reduced={reduced}
              className="border-t border-foreground/10 px-3.5 py-2.5"
            >
              {children}
            </Row>
          ) : null}

          {changelogHref ? (
            <Row
              index={rowCount - 1}
              count={rowCount}
              phase={phase}
              reduced={reduced}
              className="border-t border-foreground/10"
            >
              <a
                href={changelogHref}
                className="group/row flex items-center justify-between px-3.5 py-2.5 font-medium outline-none hover:bg-foreground/[0.05] focus-visible:bg-foreground/[0.06]"
              >
                View changelog
                <span
                  aria-hidden
                  className="transition-transform duration-200 group-hover/row:translate-x-0.5"
                >
                  →
                </span>
              </a>
            </Row>
          ) : null}
        </ul>
      </motion.div>
    </div>
  );
}
```
