An iOS-style segmented control whose raised thumb springs between segments and squishes slightly while pressed. Hairline dividers fade around the active segment, with a neutral or solid accent thumb.
Preview
Customize
9 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add segmented-controlWrites components/ui/segmented-control.tsx and components/styles/segmented-control.module.css.
Usage
Updates live as you change the controls — only props that differ from the defaults are included.
1import { SegmentedControl } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <SegmentedControl />6 );7}Props
Accessibility
Uses the WAI-ARIA radio group pattern: a role="radiogroup" with an accessible name (aria-label, or the visible caption via aria-labelledby) containing role="radio" buttons with aria-checked. Roving tabindex keeps one Tab stop; Arrow keys move and select with wrapping, Home/End jump to the first/last segment. Focus is shown with a 2px outline. Under prefers-reduced-motion the thumb jumps without spring or squish.
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 { motion, useReducedMotion } from "motion/react";5import styles from "../styles/segmented-control.module.css";6 7export interface SegmentedControlProps {8 /** Segment labels (each label is also its value) */9 options?: string[];10 /** Controlled selected value */11 value?: string;12 /** Initial selected value when uncontrolled */13 defaultValue?: string;14 /** Called with the newly selected value */15 onValueChange?: (value: string) => void;16 /** Accessible name of the control (rendered as a visible caption when showLabel is true) */17 label?: string;18 /** Show the label as a caption above the control */19 showLabel?: boolean;20 /** Size preset */21 size?: "sm" | "md" | "lg";22 /** Thumb style: a raised neutral pill (iOS) or a solid accent pill */23 variant?: "neutral" | "accent";24 /** Accent color used by the accent variant and focus tint */25 accentColor?: string;26 /** Stretch to fill the container width */27 fullWidth?: boolean;28 /** Spring stiffness of the sliding thumb */29 stiffness?: number;30 /** Disable the whole control */31 disabled?: boolean;32 /** Optional className merged onto the root */33 className?: string;34 /** Optional inline styles merged onto the root */35 style?: React.CSSProperties;36}37 38const DEFAULT_OPTIONS = ["Daily", "Weekly", "Monthly", "Yearly"];39 40export function SegmentedControl({41 options = DEFAULT_OPTIONS,42 value: valueProp,43 defaultValue,44 onValueChange,45 label = "Billing period",46 showLabel = false,47 size = "md",48 variant = "neutral",49 accentColor = "#6366f1",50 fullWidth = false,51 stiffness = 520,52 disabled = false,53 className = "",54 style,55}: SegmentedControlProps) {56 const reduce = useReducedMotion();57 const id = React.useId();58 const [inner, setInner] = React.useState<string | undefined>(defaultValue ?? options[1] ?? options[0]);59 const controlled = valueProp !== undefined;60 const selected = controlled ? valueProp : inner;61 const refs = React.useRef<Array<HTMLButtonElement | null>>([]);62 const [pressed, setPressed] = React.useState<number | null>(null);63 64 const selectedIndex = Math.max(0, options.indexOf(selected ?? ""));65 66 const select = (i: number) => {67 const v = options[i];68 if (v === undefined || disabled) return;69 if (!controlled) setInner(v);70 if (v !== selected) onValueChange?.(v);71 };72 73 const onKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>, i: number) => {74 const n = options.length;75 let next = -1;76 if (e.key === "ArrowRight" || e.key === "ArrowDown") next = (i + 1) % n;77 else if (e.key === "ArrowLeft" || e.key === "ArrowUp") next = (i - 1 + n) % n;78 else if (e.key === "Home") next = 0;79 else if (e.key === "End") next = n - 1;80 if (next < 0) return;81 e.preventDefault();82 select(next);83 refs.current[next]?.focus();84 };85 86 const transition = reduce ? { duration: 0 } : { type: "spring" as const, stiffness: Math.max(50, stiffness), damping: 36, mass: 0.9 };87 88 return (89 <div90 className={`${styles.root} ${styles[size] ?? ""} ${fullWidth ? styles.full : ""} ${className}`.trim()}91 data-variant={variant}92 style={{ "--mf-sc-accent": accentColor, ...style } as React.CSSProperties}93 >94 {showLabel && label && (95 <span id={`${id}-label`} className={styles.caption}>96 {label}97 </span>98 )}99 <div100 role="radiogroup"101 aria-labelledby={showLabel && label ? `${id}-label` : undefined}102 aria-label={showLabel && label ? undefined : label || "Options"}103 aria-disabled={disabled || undefined}104 className={styles.track}105 >106 {options.map((opt, i) => {107 const active = i === selectedIndex;108 return (109 <button110 key={`${opt}-${i}`}111 ref={(el) => {112 refs.current[i] = el;113 }}114 type="button"115 role="radio"116 aria-checked={active}117 tabIndex={active ? 0 : -1}118 disabled={disabled}119 data-state={active ? "on" : "off"}120 className={styles.segment}121 onClick={() => select(i)}122 onKeyDown={(e) => onKeyDown(e, i)}123 onPointerDown={() => setPressed(i)}124 onPointerUp={() => setPressed(null)}125 onPointerLeave={() => setPressed(null)}126 >127 {active && (128 <motion.span129 aria-hidden="true"130 layoutId={`${id}-thumb`}131 className={styles.thumb}132 initial={false}133 animate={{ scale: pressed === i && !reduce ? 0.95 : 1 }}134 transition={transition}135 />136 )}137 {i > 0 && <span aria-hidden="true" className={styles.divider} data-hidden={active || i - 1 === selectedIndex ? "true" : "false"} />}138 <span className={styles.text}>{opt}</span>139 </button>140 );141 })}142 </div>143 </div>144 );145}