UIInteractive Buttons and Controlsno dependencies

ButtonGroup

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 props

Installation

Copies the component source and its CSS Module into your project.

npx motionfoundry add button-group

Writes 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.

example.tsx
1import { ButtonGroup } from "@motionfoundry/ui";2 3export function Example() {4  return (5    <ButtonGroup />6  );7}

Props

PropTypeDefault
options

Option labels; each label is also its value.

string[]['Daily', 'Weekly', 'Monthly', 'Yearly']
value

Controlled selected option.

stringundefined
defaultValue

Initially selected option when uncontrolled.

stringoptions[1]
onValueChange

Called with the newly selected option.

(value: string) => voidundefined
label

Accessible name of the radiogroup.

string'Reporting period'
size

Size preset.

'sm' | 'md' | 'lg''md'
indicator

Raised neutral pill, accent-filled pill, or accent underline.

'pill' | 'solid' | 'underline''pill'
accentColor

Accent color for the solid and underline indicators.

string'#6366f1'
fullWidth

Fill the container and split options evenly.

booleanfalse
disabled

Disable the whole group.

booleanfalse
className

Additional class merged onto the root element.

string''
style

Inline styles merged onto the root element.

React.CSSPropertiesundefined

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

components/ui/button-group.tsx
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}