Nav Pill
A row of links with one pill that slides to the link you point at and drops out of sight when you leave.
$npx shadcn@latest add https://framebrick.co/r/nav-pill.jsonFrom the Metuo template, see it in context.
import { NavPill } from "@/components/ui/nav-pill";
const items = [
{ href: "#", label: "Home" },
{ href: "#about", label: "About" },
{ href: "#work", label: "Work" },
{ href: "#contact", label: "Contact" },
];
/* Point at a link, or tab to it: the pill comes up under it and slides along the row; leave, and it drops out of sight. */
export default function NavPillDemo() {
return <NavPill label="Main" items={items} />;
}Examples
The page you are on
The pill rests under the current page's link, and returns there when you leave.
import { NavPill } from "@/components/ui/nav-pill";
const items = [
{ href: "#", label: "Home" },
{ href: "#about", label: "About" },
{ href: "#work", label: "Work", current: true },
{ href: "#contact", label: "Contact" },
];
/* The page you are on: the pill rests under its link, the link has aria-current, and the pill returns there when you leave. */
export default function NavPillCurrentDemo() {
return <NavPill label="Main, with the current page" items={items} />;
}<NavPill label="Main" items={[{ href: "/", label: "Home" }, { href: "/work", label: "Work", current: true }]} />Installation
$ npx shadcn@latest add https://framebrick.co/r/nav-pill.json1. Install the dependencies.
npm install motion clsx tailwind-merge2. Add
cn()tolib/utils.ts(skip if shadcn already created it).import { clsx, type ClassValue } from "clsx"; import { extendTailwindMerge } from "tailwind-merge"; /* tailwind-merge must know the site's type scale (globals.css) is a font size, not a text color — otherwise cn("text-h6 text-muted-foreground") drops text-h6. */ const twMerge = extendTailwindMerge({ extend: { classGroups: { "font-size": [{ text: ["display", "h1", "h2", "h3", "h4", "h5", "h6"] }] } }, }); export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }3. Copy the component into
components/ui/nav-pill.tsx."use client"; import * as React from "react"; import Link from "next/link"; import { animate, motion, useMotionValue, useTransform, type AnimationPlaybackControls, type Transition } from "motion/react"; import { cn } from "@/lib/utils"; /* * Nav Pill — a row of links with one dark pill that slides to the link you point at (from the Metuo header). The pill rests * hidden below the row, under the first link; pointing at a link brings it up under that link, moving to another slides it * sideways, and leaving the row sends it back down. Framer's spring: bounce 0, 0.4s (Metuo's S0). Keyboard focus moves it too. * * The pill is a layer over the links, and it carries its own copy of every label in the opposite colour, so the text changes * colour exactly where the pill is — a label turning light on its own would sit light on a light row until the pill got there. * The pill ignores the pointer, so the links under it stay the links. The copies are aria-hidden, the real links are what a * screen reader and the keyboard use, and the pill is only drawn once the row has been measured, so the server sends the plain * links and nothing can mismatch. Each copy has the pill's own colour behind it (the same dark as the pill under it, so nothing * shows), which is what lets a contrast check, reading the copy's own background, see light text on dark and not the row behind. * * A link marked `current` is where the pill rests (and gets aria-current="page"), the way a nav shows the page you are on; * Metuo marks none, so its pill rests out of sight. Reduced motion jumps: animate() is imperative, so MotionConfig would not * stop it; this reads the preference itself, in an effect. Touch has no hover, so on a touch screen the row is plain links. */ const slide: Transition = { type: "spring", bounce: 0, duration: 0.4 }; export type NavPillItem = { href: string; label: React.ReactNode; /** The page you are on: the pill rests under it and the link carries aria-current. */ current?: boolean; }; export type NavPillProps = { /** Accessible name of the navigation. */ label: string; items: NavPillItem[]; /** Classes for the row: its colour, radius, padding. */ className?: string; /** Classes for every link, and for its copy in the pill: the padding and the text size. */ linkClassName?: string; }; type Box = { x: number; y: number; w: number; h: number }; const linkClasses = "inline-flex items-center rounded-md px-3 py-1.5 text-sm font-medium whitespace-nowrap"; export function NavPill({ label, items, className, linkClassName }: NavPillProps) { const nav = React.useRef<HTMLElement>(null); const links = React.useRef<(HTMLAnchorElement | null)[]>([]); const [boxes, setBoxes] = React.useState<{ links: Box[]; width: number; height: number } | null>(null); const [hovered, setHovered] = React.useState<number | null>(null); const [focused, setFocused] = React.useState<number | null>(null); const x = useMotionValue(0); const y = useMotionValue(0); const w = useMotionValue(0); const h = useMotionValue(0); const inkX = useTransform(x, (value) => -value); const inkY = useTransform(y, (value) => -value); /* Where the pill is heading, as a key, and the move under way: a new key animates, a new size of the same one jumps. */ const heading = React.useRef<string | null>(null); const moves = React.useRef<AnimationPlaybackControls[]>([]); const current = items.findIndex((item) => item.current); const at = hovered ?? focused ?? (current >= 0 ? current : null); /* The row's links, measured: before the first paint, and again when anything resizes (a web font arriving, a wider window). */ React.useLayoutEffect(() => { const element = nav.current; if (!element) return; const measure = () => { const measured = links.current.slice(0, items.length).map((link) => ({ x: link?.offsetLeft ?? 0, y: link?.offsetTop ?? 0, w: link?.offsetWidth ?? 0, h: link?.offsetHeight ?? 0 })); setBoxes({ links: measured, width: element.clientWidth, height: element.clientHeight }); }; measure(); const observer = new ResizeObserver(measure); observer.observe(element); links.current.forEach((link) => link && observer.observe(link)); return () => observer.disconnect(); }, [items]); /* The pill's place: under link `at`, or below the row (hidden by its clip) under the first link. */ React.useEffect(() => { if (!boxes || boxes.links.length === 0) return; const target: Box = at !== null && boxes.links[at] ? boxes.links[at] : { ...boxes.links[0], y: boxes.height }; const key = at === null ? "rest" : String(at); const first = heading.current === null; const still = key === heading.current; heading.current = key; moves.current.forEach((move) => move.stop()); if (first || still || window.matchMedia("(prefers-reduced-motion: reduce)").matches) { x.jump(target.x); y.jump(target.y); w.jump(target.w); h.jump(target.h); return; } moves.current = [animate(x, target.x, slide), animate(y, target.y, slide), animate(w, target.w, slide), animate(h, target.h, slide)]; }, [at, boxes, x, y, w, h]); const hover = (index: number) => (event: React.PointerEvent) => { if (event.pointerType !== "touch") setHovered(index); }; return ( <nav ref={nav} aria-label={label} onPointerLeave={() => setHovered(null)} className={cn("relative inline-flex overflow-hidden rounded-lg bg-muted p-1 text-foreground", className)} > <ul className="flex"> {items.map((item, index) => { const external = /^https?:\/\//.test(item.href); return ( <li key={item.href} className="flex"> <Link ref={(element) => { links.current[index] = element; }} href={item.href} {...(external ? { target: "_blank", rel: "noopener noreferrer" } : {})} aria-current={item.current ? "page" : undefined} onPointerEnter={hover(index)} onFocus={(event) => setFocused(event.currentTarget.matches(":focus-visible") ? index : null)} onBlur={() => setFocused(null)} className={cn(linkClasses, "outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50", linkClassName)} > {item.label} </Link> </li> ); })} </ul> {boxes && ( <motion.span aria-hidden className="pointer-events-none absolute top-0 left-0 overflow-hidden rounded-md bg-primary text-primary-foreground" style={{ x, y, width: w, height: h }}> {/* The copies of the labels, laid where the real ones are: this layer is the row's own size, moved the other way. */} <motion.span className="absolute top-0 left-0 block" style={{ x: inkX, y: inkY, width: boxes.width, height: boxes.height }}> {items.map((item, index) => { const box = boxes.links[index]; if (!box) return null; return ( <span key={item.href} className={cn(linkClasses, "absolute bg-primary", linkClassName)} style={{ left: box.x, top: box.y, width: box.w, height: box.h }}> {item.label} </span> ); })} </motion.span> </motion.span> )} </nav> ); }
Usage
import { NavPill } from "@/components/ui/nav-pill";
<NavPill
label="Main"
items={[
{ href: "/", label: "Home" },
{ href: "/about", label: "About" },
{ href: "/services", label: "Services" },
]}
/>
// the page you are on: the pill rests under it
<NavPill label="Main" items={links.map((link) => ({ ...link, current: link.href === pathname }))} />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| label | string | — | Accessible name of the navigation. |
| items | { href, label, current? }[] | — | The links, in order. A link marked current is where the pill rests, and carries aria-current="page". http(s) links open in a new tab. |
| className | string | — | Classes for the row: its colour, radius, padding (bg-muted, rounded-lg and p-1 by default). |
| linkClassName | string | — | Classes for every link and for its copy in the pill: the padding and the text size. |
- It is Metuo's header pill: a dark pill rests hidden below the row, under the first link; pointing at a link brings it up under that link (a diagonal slide the first time), moving to another slides it sideways, and leaving the row sends it back down. Framer's spring, bounce 0, 0.4 s (measured: 112 → 0 px in about 0.5 s). Keyboard focus moves it too (focus-visible only).
- The pill carries its own copy of every label in the opposite colour, so the text changes colour exactly where the pill is. A label that turned light by itself would sit light on a light row until the pill arrived; Metuo's does, and this does not. The pill ignores the pointer, so the links underneath stay the links, and the copies are aria-hidden: a screen reader and the keyboard use the real links.
- The row is measured before the first paint, and again when anything resizes it, and the pill is only drawn after that, so the server sends plain links and nothing can mismatch. A resize jumps the pill to the new box; a move to another link animates.
- A link marked current is where the pill rests, the way a nav shows the page you are on; Metuo marks none (its Home is not highlighted on Home), so its pill rests out of sight. The label roll that Metuo's links also have is Flip Button's, and is not part of this.
- Reduced motion jumps: the move is imperative animate(), which <MotionConfig> would not stop, so the component reads the preference itself, in an effect. Touch has no hover, so on a touch screen the row is plain links.
- The row does not wrap or scroll, and it clips what does not fit (the clip is what hides the resting pill). On a narrow screen show the menu button instead, as Metuo does with its hamburger.
- The pill is bg-primary with text-primary-foreground and the row is bg-muted with text-foreground, so a themed project gets its own colours; both pairs are the theme's own.
AI prompt
Paste into v0, Lovable, Bolt or Claude to rebuild this component in your own project.
Create a React + Tailwind CSS + Motion (motion/react) component called NavPill — a row of links with one pill that slides to the link you point at.
- Props: label (aria-label of the nav), items ({ href, label, current? }[]; a Next.js Link each, external http(s) links open in a new tab with rel noopener noreferrer, aria-current="page" on current), className, linkClassName.
- A nav (relative inline-flex overflow-hidden rounded-lg bg-muted p-1 text-foreground) holding a ul of links (inline-flex items-center rounded-md px-3 py-1.5 text-sm font-medium, focus ring ring-[3px] ring-ring/50) and, after it, the pill.
- Measure the links (offsetLeft, offsetTop, offsetWidth, offsetHeight) and the nav (clientWidth, clientHeight) in a layout effect and again from a ResizeObserver on the nav and every link. Draw the pill only once measured.
- The pill is an absolutely positioned, pointer-events-none, overflow-hidden rounded-md bg-primary text-primary-foreground box whose x, y, width and height are motion values. Inside it, a layer the size of the nav, moved by the negative of the pill's x and y (useTransform), holds an absolutely positioned copy of every label at its link's measured box (same classes, aria-hidden), so each label turns to text-primary-foreground exactly where the pill is.
- Target: the link that is hovered (a pointer that is not touch, set on pointer enter, cleared when the pointer leaves the nav), else the focused one (set in onFocus when event.currentTarget.matches(":focus-visible")), else the current one, else rest: the first link's box with y set to the nav's height, below the clip.
- On a change of target animate x, y, width and height with animate() and a spring (bounce 0, duration 0.4); the first placement, a change of measurements without a change of target, and prefers-reduced-motion: reduce (read in the effect) jump with .jump() instead.










