Pill Group Button
Original · freeA segmented pill button group with a sliding active indicator.
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/btn-pill-group.json"use client";
import {
useCallback,
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
type KeyboardEvent as ReactKeyboardEvent,
type ReactNode,
} from "react";
import { useReducedMotion } from "motion/react";
type PillOption = {
value: string;
label: string;
icon?: ReactNode;
};
type Tone = "neutral" | "brand" | "emerald";
type Size = "sm" | "md" | "lg";
type PillGroupProps = {
options: PillOption[];
value: string;
onChange: (value: string) => void;
ariaLabel: string;
tone?: Tone;
size?: Size;
fullWidth?: boolean;
iconOnly?: boolean;
};
const SIZE: Record<
Size,
{ pad: string; text: string; gap: string; height: string; radius: string }
> = {
sm: {
pad: "px-3",
text: "text-xs",
gap: "gap-1.5",
height: "h-8",
radius: "rounded-lg",
},
md: {
pad: "px-4",
text: "text-sm",
gap: "gap-2",
height: "h-10",
radius: "rounded-xl",
},
lg: {
pad: "px-5",
text: "text-base",
gap: "gap-2.5",
height: "h-12",
radius: "rounded-2xl",
},
};
const TONE: Record<
Tone,
{
track: string;
indicator: string;
activeText: string;
idleText: string;
ring: string;
}
> = {
neutral: {
track:
"bg-slate-100 ring-1 ring-inset ring-slate-200 dark:bg-slate-800/80 dark:ring-white/10",
indicator:
"bg-white shadow-sm shadow-slate-900/10 ring-1 ring-inset ring-slate-900/5 dark:bg-slate-700 dark:ring-white/10 dark:shadow-black/40",
activeText: "text-slate-900 dark:text-white",
idleText:
"text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-100",
ring: "focus-visible:ring-slate-900/40 dark:focus-visible:ring-white/70",
},
brand: {
track:
"bg-indigo-50 ring-1 ring-inset ring-indigo-100 dark:bg-indigo-950/40 dark:ring-indigo-400/15",
indicator:
"bg-gradient-to-b from-indigo-500 to-violet-600 shadow-md shadow-indigo-600/30 dark:shadow-indigo-900/60",
activeText: "text-white",
idleText:
"text-indigo-500/80 hover:text-indigo-700 dark:text-indigo-300/70 dark:hover:text-indigo-200",
ring: "focus-visible:ring-indigo-500 dark:focus-visible:ring-indigo-300",
},
emerald: {
track:
"bg-emerald-50 ring-1 ring-inset ring-emerald-100 dark:bg-emerald-950/40 dark:ring-emerald-400/15",
indicator:
"bg-gradient-to-b from-emerald-500 to-emerald-600 shadow-md shadow-emerald-600/30 dark:shadow-emerald-900/60",
activeText: "text-white",
idleText:
"text-emerald-600/80 hover:text-emerald-800 dark:text-emerald-300/70 dark:hover:text-emerald-200",
ring: "focus-visible:ring-emerald-500 dark:focus-visible:ring-emerald-300",
},
};
function PillGroup({
options,
value,
onChange,
ariaLabel,
tone = "neutral",
size = "md",
fullWidth = false,
iconOnly = false,
}: PillGroupProps) {
const reduce = useReducedMotion();
const groupId = useId();
const trackRef = useRef<HTMLDivElement | null>(null);
const btnRefs = useRef<(HTMLButtonElement | null)[]>([]);
const [ind, setInd] = useState<{ left: number; width: number } | null>(null);
const selectedIndex = Math.max(
0,
options.findIndex((o) => o.value === value),
);
const measure = useCallback(() => {
const track = trackRef.current;
const btn = btnRefs.current[selectedIndex];
if (!track || !btn) return;
const t = track.getBoundingClientRect();
const b = btn.getBoundingClientRect();
setInd({ left: b.left - t.left, width: b.width });
}, [selectedIndex]);
useLayoutEffect(() => {
measure();
}, [measure, size, iconOnly, options.length]);
useEffect(() => {
const track = trackRef.current;
if (!track || typeof ResizeObserver === "undefined") return;
const ro = new ResizeObserver(() => measure());
ro.observe(track);
for (const el of btnRefs.current) if (el) ro.observe(el);
window.addEventListener("resize", measure);
return () => {
ro.disconnect();
window.removeEventListener("resize", measure);
};
}, [measure]);
const focusIndex = (i: number) => {
const el = btnRefs.current[i];
if (el) {
el.focus();
onChange(options[i].value);
}
};
const onKeyDown = (e: ReactKeyboardEvent) => {
const last = options.length - 1;
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
e.preventDefault();
focusIndex(selectedIndex === last ? 0 : selectedIndex + 1);
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
e.preventDefault();
focusIndex(selectedIndex === 0 ? last : selectedIndex - 1);
} else if (e.key === "Home") {
e.preventDefault();
focusIndex(0);
} else if (e.key === "End") {
e.preventDefault();
focusIndex(last);
}
};
const s = SIZE[size];
const c = TONE[tone];
return (
<div
role="radiogroup"
aria-label={ariaLabel}
onKeyDown={onKeyDown}
ref={trackRef}
className={[
"relative inline-flex items-center p-1",
s.radius,
s.height,
c.track,
fullWidth ? "flex w-full" : "",
].join(" ")}
>
{ind && (
<span
aria-hidden="true"
className={[
"pointer-events-none absolute top-1 z-0",
s.radius,
c.indicator,
reduce ? "" : "transition-[left,width] duration-300 ease-out",
].join(" ")}
style={{
left: ind.left,
width: ind.width,
height: "calc(100% - 0.5rem)",
}}
/>
)}
{options.map((opt, i) => {
const active = i === selectedIndex;
return (
<button
key={opt.value}
type="button"
role="radio"
aria-checked={active}
aria-label={iconOnly ? opt.label : undefined}
title={iconOnly ? opt.label : undefined}
tabIndex={active ? 0 : -1}
ref={(el) => {
btnRefs.current[i] = el;
}}
onClick={() => onChange(opt.value)}
className={[
"relative z-10 inline-flex select-none items-center justify-center whitespace-nowrap font-medium outline-none",
s.pad,
s.text,
s.gap,
s.radius,
"h-full",
fullWidth ? "flex-1" : "",
active ? c.activeText : c.idleText,
reduce ? "" : "transition-colors duration-200 active:scale-[0.97]",
"focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-transparent",
c.ring,
`bpg-btn-${groupId.replace(/[:]/g, "")}`,
].join(" ")}
>
{opt.icon ? (
<span
className={[
"inline-flex shrink-0 items-center justify-center",
size === "sm" ? "h-3.5 w-3.5" : "h-4 w-4",
].join(" ")}
>
{opt.icon}
</span>
) : null}
{iconOnly ? null : opt.label}
</button>
);
})}
</div>
);
}
/* ---- inline brand-style glyphs (stroked, currentColor) ---- */
function ListIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-full w-full">
<line x1="8" y1="6" x2="21" y2="6" />
<line x1="8" y1="12" x2="21" y2="12" />
<line x1="8" y1="18" x2="21" y2="18" />
<circle cx="3.5" cy="6" r="1" />
<circle cx="3.5" cy="12" r="1" />
<circle cx="3.5" cy="18" r="1" />
</svg>
);
}
function BoardIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-full w-full">
<rect x="3" y="4" width="6" height="16" rx="1.5" />
<rect x="15" y="4" width="6" height="10" rx="1.5" />
</svg>
);
}
function CalendarIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-full w-full">
<rect x="3" y="5" width="18" height="16" rx="2" />
<line x1="3" y1="9" x2="21" y2="9" />
<line x1="8" y1="3" x2="8" y2="7" />
<line x1="16" y1="3" x2="16" y2="7" />
</svg>
);
}
function TimelineIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-full w-full">
<path d="M4 7h9" />
<path d="M4 12h14" />
<path d="M4 17h6" />
<circle cx="16" cy="7" r="1.6" />
<circle cx="20" cy="12" r="1.6" />
<circle cx="12" cy="17" r="1.6" />
</svg>
);
}
function AlignLeftIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-full w-full">
<line x1="4" y1="6" x2="20" y2="6" />
<line x1="4" y1="12" x2="13" y2="12" />
<line x1="4" y1="18" x2="16" y2="18" />
</svg>
);
}
function AlignCenterIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-full w-full">
<line x1="4" y1="6" x2="20" y2="6" />
<line x1="7" y1="12" x2="17" y2="12" />
<line x1="5" y1="18" x2="19" y2="18" />
</svg>
);
}
function AlignRightIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-full w-full">
<line x1="4" y1="6" x2="20" y2="6" />
<line x1="11" y1="12" x2="20" y2="12" />
<line x1="8" y1="18" x2="20" y2="18" />
</svg>
);
}
function SunIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-full w-full">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
</svg>
);
}
function MoonIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-full w-full">
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />
</svg>
);
}
function AutoIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-full w-full">
<circle cx="12" cy="12" r="9" />
<path d="M12 3a9 9 0 0 0 0 18z" fill="currentColor" stroke="none" />
</svg>
);
}
function Field({
label,
hint,
children,
}: {
label: string;
hint?: string;
children: ReactNode;
}) {
return (
<div className="flex flex-col gap-3">
<div className="flex items-baseline justify-between gap-4">
<span className="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
{label}
</span>
{hint ? (
<span className="text-xs font-medium text-slate-400 dark:text-slate-500">
{hint}
</span>
) : null}
</div>
{children}
</div>
);
}
export default function BtnPillGroup() {
const [view, setView] = useState("board");
const [density, setDensity] = useState("cozy");
const [plan, setPlan] = useState("yearly");
const [align, setAlign] = useState("center");
const [theme, setTheme] = useState("auto");
const [status, setStatus] = useState("active");
const [range, setRange] = useState("30d");
const viewOptions: PillOption[] = [
{ value: "list", label: "List", icon: <ListIcon /> },
{ value: "board", label: "Board", icon: <BoardIcon /> },
{ value: "calendar", label: "Calendar", icon: <CalendarIcon /> },
{ value: "timeline", label: "Timeline", icon: <TimelineIcon /> },
];
return (
<section className="relative w-full bg-slate-50 px-4 py-16 text-slate-900 dark:bg-slate-950 dark:text-slate-100 sm:px-6 sm:py-20">
<style>{`
@keyframes bpg-rise {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.bpg-rise { animation: bpg-rise 0.5s cubic-bezier(0.22, 1, 0.36, 1) both; }
@media (prefers-reduced-motion: reduce) {
.bpg-rise { animation: none !important; }
}
`}</style>
<div className="bpg-rise mx-auto flex max-w-2xl flex-col gap-10">
<header className="flex flex-col gap-2">
<span className="text-xs font-semibold uppercase tracking-[0.2em] text-indigo-500 dark:text-indigo-400">
Segmented control
</span>
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">
Pill button group
</h2>
<p className="max-w-lg text-sm leading-relaxed text-slate-500 dark:text-slate-400">
A tactile segmented control with a sliding active indicator. Real
radiogroup semantics, roving focus, and full arrow-key navigation.
</p>
</header>
<div className="flex flex-col gap-9 rounded-3xl border border-slate-200 bg-white p-6 shadow-sm shadow-slate-900/5 dark:border-white/10 dark:bg-slate-900/60 dark:shadow-black/30 sm:p-8">
<Field label="Project view" hint="icon + label · md · neutral">
<PillGroup
ariaLabel="Project view"
options={viewOptions}
value={view}
onChange={setView}
/>
</Field>
<div className="h-px w-full bg-slate-100 dark:bg-white/5" />
<Field label="Billing cycle" hint="brand · lg">
<PillGroup
ariaLabel="Billing cycle"
tone="brand"
size="lg"
options={[
{ value: "monthly", label: "Monthly" },
{ value: "yearly", label: "Yearly · save 20%" },
]}
value={plan}
onChange={setPlan}
/>
</Field>
<div className="h-px w-full bg-slate-100 dark:bg-white/5" />
<Field label="Row density" hint="sm · neutral">
<PillGroup
ariaLabel="Row density"
size="sm"
options={[
{ value: "compact", label: "Compact" },
{ value: "cozy", label: "Cozy" },
{ value: "comfortable", label: "Comfortable" },
]}
value={density}
onChange={setDensity}
/>
</Field>
<div className="h-px w-full bg-slate-100 dark:bg-white/5" />
<div className="grid gap-6 sm:grid-cols-2">
<Field label="Text align" hint="icon-only · brand">
<PillGroup
ariaLabel="Text alignment"
tone="brand"
iconOnly
options={[
{ value: "left", label: "Align left", icon: <AlignLeftIcon /> },
{ value: "center", label: "Align center", icon: <AlignCenterIcon /> },
{ value: "right", label: "Align right", icon: <AlignRightIcon /> },
]}
value={align}
onChange={setAlign}
/>
</Field>
<Field label="Appearance" hint="icon-only · neutral">
<PillGroup
ariaLabel="Appearance"
iconOnly
options={[
{ value: "light", label: "Light", icon: <SunIcon /> },
{ value: "dark", label: "Dark", icon: <MoonIcon /> },
{ value: "auto", label: "System", icon: <AutoIcon /> },
]}
value={theme}
onChange={setTheme}
/>
</Field>
</div>
<div className="h-px w-full bg-slate-100 dark:bg-white/5" />
<Field label="Deployment status" hint="emerald · md">
<PillGroup
ariaLabel="Deployment status"
tone="emerald"
options={[
{ value: "active", label: "Active" },
{ value: "paused", label: "Paused" },
{ value: "archived", label: "Archived" },
]}
value={status}
onChange={setStatus}
/>
</Field>
<div className="h-px w-full bg-slate-100 dark:bg-white/5" />
<Field label="Date range" hint="full width · neutral">
<PillGroup
ariaLabel="Date range"
fullWidth
options={[
{ value: "7d", label: "7 days" },
{ value: "30d", label: "30 days" },
{ value: "90d", label: "90 days" },
{ value: "12m", label: "12 months" },
]}
value={range}
onChange={setRange}
/>
</Field>
</div>
<p className="text-center text-xs text-slate-400 dark:text-slate-500">
Selected — view: {view} · plan: {plan} · align: {align} · range: {range}
</p>
</div>
</section>
);
}Dependencies
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Shimmer Button
MITA dark pill button with a continuous conic-gradient light shimmer sweeping around its border.

Rainbow Gradient Button
MITA button wrapped in an animated multi-colour gradient border with a matching blurred underglow.

Interactive Hover Button
MITA pill button whose dot expands to fill the surface and reveals an arrow label on hover.

Pulsating Button
MITA solid button that emits a soft, continuously pulsing glow ring to draw the eye to the primary action.

Shiny Button
MITA frosted-glass button with a spring-animated light sweeping across its text and border.

Solid Set Button
OriginalA set of solid buttons: primary, secondary, success and danger, in three sizes.

Outline Set Button
OriginalOutline button variants across colours and sizes with a hover fill.

Ghost Set Button
OriginalGhost and text buttons with subtle hover backgrounds.

Soft Set Button
OriginalSoft, tinted buttons across semantic colours.

Gradient Set Button
OriginalGradient-filled buttons with a hover shift.
Icon Leading Button
OriginalButtons with leading and trailing icons.
Icon Only Button
OriginalSquare and circular icon-only buttons with tooltips and aria-labels.

