An attached single-select button group (segmented control) whose active indicator springs between options, measured from the real button widths. Supports pill, solid and underline indicators and full arrow-key navigation.
Preview
Customize
7 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add button-groupWrites components/ui/button-group.tsx and components/styles/button-group.module.css.
Usage
Updates live as you change the controls — only props that differ from the defaults are included.
1import { ButtonGroup } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <ButtonGroup />6 );7}Props
Accessibility
Follows the WAI-ARIA radio group pattern: role="radiogroup" labelled by `label`, each option is role="radio" with aria-checked. Roving tabindex makes the group a single Tab stop; Arrow keys move focus and select (wrapping), Home/End jump to the first/last option. The sliding indicator is aria-hidden. Under prefers-reduced-motion the indicator jumps without a transition.
Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.
Source
1"use client";2 3import * as React from "react";4import styles from "../styles/button-group.module.css";5 6export interface ButtonGroupProps {7 /** Option labels (each label is also its value) */8 options?: string[];9 /** Controlled selected option */10 value?: string;11 /** Initially selected option when uncontrolled (defaults to the second option) */12 defaultValue?: string;13 /** Called with the newly selected option */14 onValueChange?: (value: string) => void;15 /** Accessible name of the group */16 label?: string;17 /** Size preset */18 size?: "sm" | "md" | "lg";19 /** Active indicator style */20 indicator?: "pill" | "solid" | "underline";21 /** Accent color for the solid / underline indicator */22 accentColor?: string;23 /** Stretch to fill the container, splitting options evenly */24 fullWidth?: boolean;25 /** Disable the whole group */26 disabled?: boolean;27 /** Optional className merged onto the root */28 className?: string;29 /** Optional inline styles merged onto the root */30 style?: React.CSSProperties;31}32 33const DEFAULT_OPTIONS = ["Daily", "Weekly", "Monthly", "Yearly"];34const useIsoLayoutEffect = typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;35 36export function ButtonGroup({37 options = DEFAULT_OPTIONS,38 value: valueProp,39 defaultValue,40 onValueChange,41 label = "Reporting period",42 size = "md",43 indicator = "pill",44 accentColor = "#6366f1",45 fullWidth = false,46 disabled = false,47 className = "",48 style,49}: ButtonGroupProps) {50 const items = options.length > 0 ? options : DEFAULT_OPTIONS;51 const [inner, setInner] = React.useState(defaultValue ?? items[Math.min(1, items.length - 1)]);52 const value = valueProp !== undefined ? valueProp : inner;53 const activeIndex = items.indexOf(value);54 const listRef = React.useRef<HTMLDivElement>(null);55 const btnRefs = React.useRef<(HTMLButtonElement | null)[]>([]);56 const [box, setBox] = React.useState<{ x: number; w: number } | null>(null);57 const [animate, setAnimate] = React.useState(false);58 59 const select = (next: string) => {60 if (disabled || next === value) return;61 if (valueProp === undefined) setInner(next);62 onValueChange?.(next);63 };64 65 const measure = React.useCallback(() => {66 const el = btnRefs.current[activeIndex];67 setBox(el ? { x: el.offsetLeft, w: el.offsetWidth } : null);68 }, [activeIndex]);69 70 useIsoLayoutEffect(() => {71 measure();72 }, [measure, items.join("|"), size, fullWidth]);73 74 React.useEffect(() => {75 const id = requestAnimationFrame(() => setAnimate(true));76 const ro = typeof ResizeObserver !== "undefined" && listRef.current ? new ResizeObserver(() => measure()) : null;77 if (ro && listRef.current) ro.observe(listRef.current);78 return () => {79 cancelAnimationFrame(id);80 ro?.disconnect();81 };82 }, [measure]);83 84 const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {85 const current = Math.max(0, btnRefs.current.findIndex((b) => b === document.activeElement));86 let next = -1;87 if (e.key === "ArrowRight" || e.key === "ArrowDown") next = (current + 1) % items.length;88 else if (e.key === "ArrowLeft" || e.key === "ArrowUp") next = (current - 1 + items.length) % items.length;89 else if (e.key === "Home") next = 0;90 else if (e.key === "End") next = items.length - 1;91 if (next < 0) return;92 e.preventDefault();93 btnRefs.current[next]?.focus();94 select(items[next]);95 };96 97 const rootStyle = {98 "--mf-bgp-accent": accentColor,99 "--mf-bgp-x": box ? `${box.x}px` : "0px",100 "--mf-bgp-w": box ? `${box.w}px` : "0px",101 ...style,102 } as React.CSSProperties;103 104 return (105 <div106 ref={listRef}107 role="radiogroup"108 aria-label={label}109 aria-disabled={disabled || undefined}110 onKeyDown={onKeyDown}111 data-indicator={indicator}112 data-animate={animate || undefined}113 className={[styles.root, styles[size], fullWidth ? styles.full : "", className].filter(Boolean).join(" ")}114 style={rootStyle}115 >116 {box ? <span className={styles.indicator} aria-hidden="true" /> : null}117 {items.map((opt, i) => {118 const checked = i === activeIndex;119 const focusable = activeIndex === -1 ? i === 0 : checked;120 return (121 <button122 key={`${opt}-${i}`}123 ref={(el) => {124 btnRefs.current[i] = el;125 }}126 type="button"127 role="radio"128 aria-checked={checked}129 tabIndex={focusable ? 0 : -1}130 disabled={disabled}131 data-state={checked ? "on" : "off"}132 className={styles.item}133 onClick={() => select(opt)}134 >135 {opt}136 </button>137 );138 })}139 </div>140 );141}