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.
- Badge
- Hover
- Changelog
Uses motion, clsx, tailwind-merge
The agent that does the busywork
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.
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>
);
}
Installation
Add the component on its own with the CLI, or copy the file in by hand.
The CLI writes components/ui/release-pill.tsx and installs motion, clsx and tailwind-merge. It needs the @keyframeui registry in components.json; add it once per project.
npx shadcn@latest add @keyframeui/release-pillInstall the dependencies
npm i motion clsx tailwind-mergeAdd the cn helper
Skip this if you already use shadcn/ui.
lib/utils.tsimport { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }Copy the source into your project
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> ); }Update the import paths
The file imports
cnfrom@/lib/utils. Change the path if your project uses a different alias.
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.
- Pay for the runs you make. Nothing else, no seats.
- Day 2Slack approvalsTue
- Day 1Agents 2.0Mon
- Locked.Day 4 · Tomorrowin 14h 32m
- View changelog
One new thing, every morning
Launch week is here. Three days in, three things shipped, and the best one is still to come.
Remind me tomorrow"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.
Builds that finish before you look away
Acme runs your tasks as a graph, caches what hasn't changed, and tells you why when something does.
"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'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 |
|---|---|---|---|
releasesRequired | Release[] | None | 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 | None | 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 | None | An extra row at the end of the list, after the releases: an upgrade command, a locked next release. |
className | string | None | Extra classes for the wrapper. Set the open card's width with [--release-pill-width:26rem] (default 22rem). |
releasesRequiredType
Release[]The releases, newest first; the first is the latest. Each one is
{ version, title, date?, href?, summary?, label? }.versionis shown as written ("v2.4", "Day 3") and its digits roll from the previous release's on first view.summaryis the line under the latest title, andlabelis a small node, like a Feature tag, shown with the release in the open card.tagType
stringDefault"New"The small tag at the start of the pill.
changelogHrefType
stringAdds a "View changelog →" link at the bottom of the open card.
peekType
booleanDefaultfalseAbout 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.
cycleType
boolean | numberDefaultfalseStep the closed pill through the latest releases every 4s, each title sliding up as the next arrives. A number sets how many releases;
trueis 3. A pointer or focus stops it, and opening brings the latest back.alignType
"center" | "start"Default"center"Where the card grows from: out of the pill's middle (above a centered headline) or out of its left edge.
childrenType
React.ReactNodeAn extra row at the end of the list, after the releases: an upgrade command, a locked next release.
classNameType
stringExtra classes for the wrapper. Set the open card's width with
[--release-pill-width:26rem](default22rem).