UIForms and Inputsmotion/react

SegmentedControl

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 props
520

Installation

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

npx motionfoundry add segmented-control

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

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

Props

PropTypeDefault
options

Segment labels; each label is also its value.

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

Controlled selected value.

stringundefined
defaultValue

Initial selected value when uncontrolled (falls back to the second, then first option).

stringsecond option
onValueChange

Called with the newly selected value.

(value: string) => voidundefined
label

Accessible name of the control.

string'Billing period'
showLabel

Render the label as a visible caption above the control.

booleanfalse
size

Height, padding and type size preset.

'sm' | 'md' | 'lg''md'
variant

Raised neutral thumb (iOS look) or a solid accent thumb with white text.

'neutral' | 'accent''neutral'
accentColor

Thumb color of the accent variant.

string'#6366f1'
fullWidth

Stretch the control to the container width with equal segments.

booleanfalse
stiffness

Spring stiffness of the sliding thumb.

number520
disabled

Disable the whole control.

booleanfalse
className

Additional class merged onto the root element.

string''
style

Inline styles merged onto the root element.

React.CSSPropertiesundefined

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

components/ui/segmented-control.tsx
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}