Web InnoventixFreeCode

Pill Group Button

Original · free

A segmented pill button group with a sliding active indicator.

byWeb InnoventixReact + Tailwind
btnpillgroupbuttons
Open

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
btn-pill-group.tsx
"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

motion

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 quote

Similar components

Browse all →