Skip to content
Uxelith
Sign in
Freev1.0.0Forms

Segmented Control

A row of choices with a thumb that springs to the selected one — drag it like iOS, and the selected text colour is clipped to the thumb as it glides. A real radio group that submits with forms.

pnpm dlx shadcn add …/r/segmented-control.json
Licence
MIT
Packages
3
Updated
10 Oct 2026
Live

Installation

  1. Run this in a project set up with shadcn/ui:

    $ pnpm dlx shadcn@latest add https://uxelith.com/r/segmented-control.json
  2. It adds these packages for you:

    clsxtailwind-mergemotion
  3. The source lands in your components folder as segmented-control.tsx. It's yours: change anything.

Usage

example.tsx
"use client"

import { useState } from "react"
import { SegmentedControl } from "@/components/segmented-control"

export function ViewSwitch() {
  const [view, setView] = useState("week")
  return (
    <SegmentedControl
      label="Calendar view"
      value={view}
      onValueChange={setView}
      options={[
        { value: "day", label: "Day" },
        { value: "week", label: "Week" },
        { value: "month", label: "Month" },
      ]}
    />
  )
}

Source

One file, 267 lines, nothing hidden. Read it before you install it.

segmented-control.tsx
"use client";

import { motion, useReducedMotion } from "motion/react";
import * as React from "react";

import { cn } from "@/lib/utils";

export interface SegmentedOption {
  value: string;
  label: React.ReactNode;
  /** Optional icon before the label. */
  icon?: React.ReactNode;
  disabled?: boolean;
}

export interface SegmentedControlProps extends Omit<React.ComponentProps<"div">, "defaultValue" | "onChange"> {
  options: SegmentedOption[];
  /** Accessible name for the group, e.g. "View". */
  label: string;
  /** Controlled selection. */
  value?: string;
  /** Initial selection when uncontrolled. Defaults to the first enabled option. */
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  size?: "sm" | "md";
  /** Stretch segments to fill the full width. */
  fullWidth?: boolean;
  /** Submit the selection with forms under this name. */
  name?: string;
}

// Motion presets (ADR-010).
const SPRING_SNAPPY = { type: "spring", visualDuration: 0.2, bounce: 0 } as const;
const SPRING_SMOOTH = { type: "spring", visualDuration: 0.35, bounce: 0.15 } as const;
const PAD = 4;
const DRAG_START = 3;

type Box = { left: number; width: number };

/**
 * A row of mutually exclusive choices with a thumb that glides to the
 * selected one — for settings and view switches, not for showing panels
 * (use tabs for that).
 *
 * - The selected text colour is clipped to the thumb, so it changes exactly
 *   where the thumb is, mid-glide too.
 * - Drag the thumb across and let go to choose, like iOS.
 * - A radio group: ←/→ (and ↑/↓) move and select, Home/End jump, disabled
 *   options are skipped, and only the selected option is in the Tab order.
 * - With `name`, a hidden input submits the value with forms.
 * - The thumb moves instantly under `prefers-reduced-motion`.
 */
export function SegmentedControl({
  options,
  label,
  value: valueProp,
  defaultValue,
  onValueChange,
  size = "md",
  fullWidth = false,
  name,
  className,
  ...props
}: SegmentedControlProps) {
  const reduceMotion = useReducedMotion();
  const firstEnabled = options.find((o) => !o.disabled)?.value;
  const [uncontrolled, setUncontrolled] = React.useState(defaultValue ?? firstEnabled);
  const requested = valueProp ?? uncontrolled;
  const selected = options.some((o) => o.value === requested && !o.disabled) ? requested : firstEnabled;
  const rootRef = React.useRef<HTMLDivElement>(null);
  const refs = React.useRef(new Map<string, HTMLButtonElement>());
  const [boxes, setBoxes] = React.useState<{ root: number; items: Map<string, Box> } | null>(null);
  const [pressed, setPressed] = React.useState(false);
  const [dragLeft, setDragLeft] = React.useState<number | null>(null);
  const drag = React.useRef<{ startX: number; from: number; moved: boolean } | null>(null);
  const suppressClick = React.useRef(false);

  // Measure segments so the thumb and the clipped text can travel between them.
  React.useLayoutEffect(() => {
    const root = rootRef.current;
    if (!root) return;
    const measure = () => {
      const items = new Map<string, Box>();
      refs.current.forEach((el, value) => items.set(value, { left: el.offsetLeft, width: el.offsetWidth }));
      setBoxes({ root: root.clientWidth, items });
    };
    measure();
    const observer = new ResizeObserver(measure);
    observer.observe(root);
    return () => observer.disconnect();
  }, [options, size, fullWidth]);

  const select = (next: string) => {
    if (next === selected) return;
    if (valueProp === undefined) setUncontrolled(next);
    onValueChange?.(next);
  };

  const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
    const enabled = options.filter((o) => !o.disabled);
    const index = enabled.findIndex((o) => o.value === selected);
    let target: SegmentedOption | undefined;
    if (event.key === "ArrowRight" || event.key === "ArrowDown") target = enabled[(index + 1) % enabled.length];
    else if (event.key === "ArrowLeft" || event.key === "ArrowUp") target = enabled[(index - 1 + enabled.length) % enabled.length];
    else if (event.key === "Home") target = enabled[0];
    else if (event.key === "End") target = enabled.at(-1);
    if (!target) return;
    event.preventDefault();
    select(target.value);
    refs.current.get(target.value)?.focus();
  };

  const current = selected !== undefined ? boxes?.items.get(selected) : undefined;
  const thumb = current && { left: dragLeft ?? current.left, width: current.width };
  // The enabled segment whose centre is nearest the dragged thumb's centre.
  const nearest = (left: number) => {
    let best: string | undefined;
    let distance = Infinity;
    for (const o of options) {
      const b = boxes?.items.get(o.value);
      if (o.disabled || !b || !current) continue;
      const d = Math.abs(b.left + b.width / 2 - (left + current.width / 2));
      if (d < distance) [best, distance] = [o.value, d];
    }
    return best;
  };

  const radius = size === "sm" ? 6 : 8;
  const segment = cn(
    "relative inline-flex items-center justify-center gap-2 whitespace-nowrap font-medium [&_svg]:size-4 [&_svg]:shrink-0",
    size === "sm" ? "h-7 rounded-md px-3 text-xs" : "h-9 rounded-lg px-4 text-sm",
    fullWidth && "flex-1",
  );
  const content = (o: SegmentedOption) => (
    <>
      {o.icon}
      {o.label}
    </>
  );
  const travel = dragLeft !== null || reduceMotion ? { duration: 0 } : pressed ? SPRING_SNAPPY : SPRING_SMOOTH;

  return (
    <div
      ref={rootRef}
      role="radiogroup"
      aria-label={label}
      onKeyDown={onKeyDown}
      onPointerMove={(event) => {
        const d = drag.current;
        if (!d || !boxes || !current) return;
        const dx = event.clientX - d.startX;
        if (!d.moved && Math.abs(dx) < DRAG_START) return;
        d.moved = true;
        setDragLeft(Math.min(boxes.root - PAD - current.width, Math.max(PAD, d.from + dx)));
      }}
      onPointerUp={() => {
        const d = drag.current;
        drag.current = null;
        setPressed(false);
        if (d?.moved && dragLeft !== null) {
          suppressClick.current = true;
          const next = nearest(dragLeft);
          if (next) select(next);
        }
        setDragLeft(null);
      }}
      onPointerCancel={() => {
        drag.current = null;
        setPressed(false);
        setDragLeft(null);
      }}
      className={cn(
        "relative inline-flex touch-pan-y select-none rounded-xl border border-border bg-muted/60 p-1 shadow-[inset_0_1px_2px_rgb(0_0_0/0.06)]",
        fullWidth && "flex w-full",
        className,
      )}
      {...props}
    >
      {thumb && (
        <motion.span
          aria-hidden="true"
          initial={false}
          animate={{ x: thumb.left, width: thumb.width, scale: pressed && dragLeft === null && !reduceMotion ? 0.96 : 1 }}
          transition={travel}
          className={cn(
            "pointer-events-none absolute inset-y-1 left-0 border border-border bg-background shadow-[0_1px_2px_rgb(0_0_0/0.08),0_4px_12px_-4px_rgb(0_0_0/0.14)]",
            "dark:border-input dark:bg-[color-mix(in_oklab,var(--foreground)_9%,var(--muted))] dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.06),0_4px_12px_-4px_rgb(0_0_0/0.5)]",
            size === "sm" ? "rounded-md" : "rounded-lg",
          )}
        />
      )}
      {options.map((option) => {
        const isSelected = option.value === selected;
        return (
          <button
            key={option.value}
            ref={(node) => {
              if (node) refs.current.set(option.value, node);
              else refs.current.delete(option.value);
            }}
            type="button"
            role="radio"
            aria-checked={isSelected}
            tabIndex={isSelected ? 0 : -1}
            disabled={option.disabled}
            onPointerDown={(event) => {
              if (event.button !== 0) return;
              setPressed(true);
              if (isSelected && current) {
                rootRef.current?.setPointerCapture?.(event.pointerId);
                drag.current = { startX: event.clientX, from: current.left, moved: false };
              }
            }}
            onPointerLeave={() => !drag.current && setPressed(false)}
            onClick={() => {
              if (suppressClick.current) {
                suppressClick.current = false;
                return;
              }
              select(option.value);
            }}
            className={cn(
              segment,
              "outline-none transition-colors duration-150 motion-reduce:transition-none",
              "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
              "disabled:pointer-events-none disabled:opacity-40",
              "text-muted-foreground hover:text-foreground",
              // Before measuring (server render), the selected segment draws its own thumb.
              !thumb && isSelected && "text-foreground",
            )}
          >
            {!thumb && isSelected && (
              <span
                aria-hidden="true"
                className={cn(
                  "absolute inset-0 border border-border bg-background shadow-sm dark:border-input dark:bg-[color-mix(in_oklab,var(--foreground)_9%,var(--muted))]",
                  size === "sm" ? "rounded-md" : "rounded-lg",
                )}
              />
            )}
            <span className="relative inline-flex items-center gap-2">{content(option)}</span>
          </button>
        );
      })}
      {thumb && boxes && (
        // The same labels in the selected colour, clipped to the thumb.
        <motion.span
          aria-hidden="true"
          initial={false}
          animate={{
            clipPath: `inset(${PAD}px ${boxes.root - thumb.left - thumb.width}px ${PAD}px ${thumb.left}px round ${radius}px)`,
          }}
          transition={travel}
          className={cn("pointer-events-none absolute inset-0 flex p-1 text-foreground", fullWidth && "w-full")}
        >
          {options.map((option) => (
            // A disabled option is never selected: keep its room, draw nothing.
            <span key={option.value} className={cn(segment, option.disabled && "invisible")}>
              {content(option)}
            </span>
          ))}
        </motion.span>
      )}
      {name && <input type="hidden" name={name} value={selected ?? ""} />}
    </div>
  );
}

Props

PropTypeDefaultDescription
options{ value: string; label: ReactNode; icon?: ReactNode; disabled?: boolean }[]—The choices, in order.
labelstring—Accessible name for the group, e.g. "View".
value / defaultValuestring— / first enabledControlled or initial selection.
onValueChange(value: string) => void—Called with the new selection.
size"sm" | "md""md"Segment height and text size.
fullWidthbooleanfalseStretch segments to fill the row.
namestring—Submit the selection with forms.

Accessibility

  • A radio group: ←/→ and ↑/↓ move and select, Home/End jump, disabled options are skipped.
  • Only the selected option is in the Tab order.
  • Use tabs, not this, when the choice swaps a panel of content.
  • The clipped text copy is hidden from assistive tech.
  • The thumb moves instantly under prefers-reduced-motion.

Changelog

  1. v1.0.0Latest

    First release.

More in Forms

All 10 in Forms
  • FormsFree

    Animated Input

    A text field whose label floats out of the way, with examples that take turns while it's empty, an error that shakes once, a tick for a valid value and sent text that drifts away.

  • FormsPro

    Date Picker

    Single dates and ranges in a keyboard-first calendar, with one-tap presets ("Last 7 days"), months that slide the way you go, min/max, unavailable days and ISO strings for forms.

  • FormsPro

    File Dropzone

    Drop, paste or browse for files: the zone lights up as a file crosses the page and warns before a wrong type lands; files show thumbnails and upload with live, cancellable progress.