Pill Tabs
Tabs in a dark pill, with the light pill flowing between them and a count beside each.
Preview
"use client";
import { useState } from "react";
import { useAutoplay } from "../demo-context";
import { PillTabs } from "./PillTabs";
const tabs = [
{ value: "all", label: "All", count: 24 },
{ value: "open", label: "Open", count: 8 },
{ value: "paid", label: "Paid", count: 13 },
{ value: "shipped", label: "Shipped", count: 3 },
];
export default function Demo() {
const [value, setValue] = useState("open");
const [turn, setTurn] = useState(0);
useAutoplay(() => {
setValue(tabs[[0, 3, 1, 2][turn % 4]].value);
setTurn((t) => t + 1);
}, 1100);
return <PillTabs label="Orders" tabs={tabs} value={value} onChange={setValue} />;
}Installation
npx @usefluid/cli add pill-tabspnpm dlx @usefluid/cli add pill-tabsyarn dlx @usefluid/cli add pill-tabsbunx @usefluid/cli add pill-tabsInstall the engine.
Terminal npm i @usefluid/motionCopy these files into your project.
components/fluid/pill-tabs/PillTabs.tsx "use client"; import { useSpring } from "@usefluid/motion/react"; import { type KeyboardEvent, type ReactNode, useEffect, useLayoutEffect, useRef, useState } from "react"; import styles from "./PillTabs.module.css"; const LEAD = { w: 30, z: 0.82 }; const TRAIL = { w: 16, z: 0.8 }; type Tab = { value: string; label: ReactNode; count?: number }; type Props = { label: string; tabs: Tab[]; value: string; onChange(value: string): void }; export function PillTabs({ label, tabs, value, onChange }: Props) { const list = useRef<HTMLDivElement>(null); const pill = useRef<HTMLSpanElement>(null); const [box, setBox] = useState<{ left: number; right: number } | null>(null); const last = useRef({ left: 0, placed: false }); useLayoutEffect(() => { const measure = () => { const el = list.current?.querySelector<HTMLElement>('[aria-selected="true"]'); if (el) setBox({ left: el.offsetLeft, right: el.offsetLeft + el.offsetWidth }); }; measure(); const observer = new ResizeObserver(measure); if (list.current) observer.observe(list.current); return () => observer.disconnect(); }, [value]); useEffect(() => { if (box) last.current = { left: box.left, placed: true }; }, [box]); const right = !box || box.left >= last.current.left; const immediate = !last.current.placed; const edges = useRef({ left: 0, right: 0 }); const paint = () => { if (!pill.current) return; pill.current.style.transform = `translateX(${edges.current.left}px)`; pill.current.style.width = `${Math.max(0, edges.current.right - edges.current.left)}px`; }; useSpring({ v: box?.left ?? 0 }, ({ v }) => { edges.current.left = v; paint(); }, { ...(right ? TRAIL : LEAD), immediate }); useSpring({ v: box?.right ?? 0 }, ({ v }) => { edges.current.right = v; paint(); }, { ...(right ? LEAD : TRAIL), immediate }); const move = (e: KeyboardEvent, i: number) => { const d = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0; if (!d) return; e.preventDefault(); const next = (i + d + tabs.length) % tabs.length; onChange(tabs[next].value); list.current?.querySelectorAll<HTMLElement>('[role="tab"]')[next]?.focus(); }; return ( <div ref={list} role="tablist" aria-label={label} className={styles.list}> <span ref={pill} className={styles.pill} data-on={box ? "" : undefined} aria-hidden /> {tabs.map((tab, i) => ( <button key={tab.value} type="button" role="tab" aria-selected={tab.value === value} tabIndex={tab.value === value ? 0 : -1} className={styles.tab} onClick={() => onChange(tab.value)} onKeyDown={(e) => move(e, i)} > {tab.label} {tab.count !== undefined && <span className={styles.count}>{tab.count}</span>} </button> ))} </div> ); }components/fluid/pill-tabs/PillTabs.module.css .list { position: relative; display: inline-flex; max-width: 100%; padding: 6px; overflow-x: auto; border-radius: 999px; background: var(--fluid-accent); scrollbar-width: none; isolation: isolate; } .pill { position: absolute; z-index: -1; top: 6px; bottom: 6px; left: 0; border-radius: 999px; background: var(--fluid-on-accent); opacity: 0; } .pill[data-on] { opacity: 1; } .tab { display: inline-flex; align-items: center; gap: 8px; min-height: 44px; padding: 0 18px; border: 0; border-radius: 999px; background: none; color: var(--fluid-on-accent); font: 500 15px/1 var(--fluid-font); white-space: nowrap; cursor: pointer; transition: color 200ms ease; } .tab[aria-selected="true"] { color: var(--fluid-accent); } .tab:focus-visible { outline: 2px solid var(--fluid-focus); outline-offset: 2px; } .count { display: grid; place-items: center; min-width: 24px; height: 22px; padding: 0 7px; border-radius: 999px; background: color-mix(in srgb, currentColor 16%, transparent); font-size: 12px; font-variant-numeric: tabular-nums; }Import the theme once, then point its variables at your own tokens.
layout.tsx import "@usefluid/motion/theme.css";
Usage
import { PillTabs } from "./PillTabs";
<PillTabs label="Inbox" value={tab} onChange={setTab} tabs={[{ value: "new", label: "New", count: 3 }, { value: "all", label: "All" }]} />API reference
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | — | Accessible name of the tab list. |
value | string | — | The current tab. |
onChange | (value) => void | — | |
tabs | { value, label, count? }[] | — |
Keyboard and screen readers
A tab list: arrow keys move between tabs. Give the panel below role="tabpanel".
Notes
For filters that do not switch panels, use SegmentedPicker.
Every colour comes from the --fluid-* variables, and the piece is yours to change.
How it is made
The same flow as the site's navbar: each edge of the light pill has its own spring, and the front one leaves before the back one. The arrow keys move between tabs, and each tab can carry a count.
For your agent
For your agent
Add Pill Tabs to my project with the Fluid library. Use PillTabs on the filtered lists of my product, with the real counts. Read my project first. Use my logo, my colours and my font through the --fluid-* variables. Keep 44px touch targets, visible focus and reduced motion. If I have no SVG logo, ask before making one up.