Components

Getting started

Components144

More

One of a few options side by side: the thumb is thrown to the new segment while its width eases to the new label.

Used forView switchesBilling periodChart rangesEditor modes

Install with the shadcn CLI

$ npx shadcn@latest add https://hairlineui.com/r/segmented-control.json

Templates

Need the whole page?

43 Next.js landing pages, each designed from scratch. Try any of them live; one is $39, all of them $99, and 5 are free.

All 43 templates

Where it’s used

  • View switches

    Day, week and month on a calendar.

  • Billing period

    Monthly or yearly above a price.

  • Chart ranges

    7d, 30d, 90d on a dashboard card.

  • Editor modes

    Write and preview with icons.

What it handles for you

Tabs, toggle groups and segmented controls get mixed up; this is the single-choice one, with radio semantics and one tab stop.

Instead of wiring uptoggle group for single choicesliding highlight
  1. One thrown thumb

    Measured from layout, retargets mid-flight, first placement without motion.

  2. Radio semantics

    role="radiogroup", arrows select, one tab stop.

  3. Icons or text

    Icon-only segments take an ariaLabel.

  4. Block

    Equal segments that fill the parent.

Overview

Segmented controls usually blink their highlight from one segment to the next. Here the thumb is one surface thrown to the new segment with a slight overshoot while its width eases to the new label; it gives a little under the finger. It is a radiogroup, so arrow keys move and select.

States

pick
The thumb is thrown to the new segment (440ms, slight overshoot) and its width eases (400ms).
press
The thumb scales to 0.97 under the finger.
keys
Arrows move and select; Home and End jump.

Usage

usage.tsx
import { SegmentedControl } from "@/components/ui/segmented-control";<SegmentedControl label="View" defaultValue="week" options={[  { value: "day", label: "Day" },  { value: "week", label: "Week" },  { value: "month", label: "Month" },]} />

Props

PropTypeDescription
optionsSegmentOption[]value, label, icon, ariaLabel, disabled.
value / defaultValuestringChosen value.
onValueChange(value: string) => voidCalled on choice.
labelstringAccessible name.
size"sm" | "md"default "md"28px or 36px tall.
blockbooleandefault falseFill the parent with equal segments.

Accessibility and motion

  • Reduced motion: the thumb moves at once.
  • Use Tabs when each choice shows its own panel.

Requirements

React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style, light and dark). Icons from lucide-react.

The CLI also installs ui-interaction-motion.

Source

components/segmented-control.tsx
"use client";import * as React from "react";import { EASE_OUT, EASE_THROW, INTERACTION_FOCUS, useInteractionReducedMotion } from "./ui-interaction-motion";/* ───────────────────────────────────────────────────────── * SEGMENTED CONTROL: one of a few, side by side * *   pick     the thumb is thrown to the new segment with a slight *            overshoot while its width eases to the new label *   press    the thumb gives a little under the finger *   keys     a radiogroup: arrows move and select, Home/End jump; *            one tab stop for the whole control * ───────────────────────────────────────────────────────── */export interface SegmentOption {  value: string;  label: React.ReactNode;  icon?: React.ReactNode;  /** Required when `label` isn't text (icon-only segments). */  ariaLabel?: string;  disabled?: boolean;}export interface SegmentedControlProps {  options: SegmentOption[];  value?: string;  defaultValue?: string;  onValueChange?: (value: string) => void;  /** Accessible name of the group. */  label: string;  /** 28px or 36px tall. */  size?: "sm" | "md";  /** Stretch to the parent's width with equal segments. */  block?: boolean;  className?: string;}type Mark = { x: number; w: number };export function SegmentedControl({ options, value, defaultValue, onValueChange, label, size = "md", block, className = "" }: SegmentedControlProps) {  const reduced = useInteractionReducedMotion();  const rootRef = React.useRef<HTMLDivElement>(null);  const [own, setOwn] = React.useState(defaultValue ?? options[0]?.value);  const current = value !== undefined ? value : own;  const [mark, setMark] = React.useState<Mark | null>(null);  const [ready, setReady] = React.useState(false);  const [pressing, setPressing] = React.useState(false);  React.useLayoutEffect(() => {    const root = rootRef.current;    if (!root) return;    const measure = () => {      const el = current ? root.querySelector<HTMLElement>(`[data-segment="${CSS.escape(current)}"]`) : null;      if (!el) return setMark(null);      const next = { x: el.offsetLeft, w: el.offsetWidth };      setMark((p) => (p && p.x === next.x && p.w === next.w ? p : next));    };    measure();    const ro = new ResizeObserver(measure);    ro.observe(root);    root.querySelectorAll("[data-segment]").forEach((el) => ro.observe(el));    return () => ro.disconnect();  }, [current, options]);  React.useEffect(() => {    if (!mark || ready) return;    const frame = requestAnimationFrame(() => setReady(true));    return () => cancelAnimationFrame(frame);  }, [mark, ready]);  const pick = (v: string) => {    if (value === undefined) setOwn(v);    onValueChange?.(v);  };  const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {    const enabled = options.filter((o) => !o.disabled);    const index = enabled.findIndex((o) => o.value === current);    let next = -1;    if (e.key === "ArrowRight" || e.key === "ArrowDown") next = (index + 1) % enabled.length;    else if (e.key === "ArrowLeft" || e.key === "ArrowUp") next = (index - 1 + enabled.length) % enabled.length;    else if (e.key === "Home") next = 0;    else if (e.key === "End") next = enabled.length - 1;    if (next < 0) return;    e.preventDefault();    const v = enabled[next].value;    pick(v);    rootRef.current?.querySelector<HTMLElement>(`[data-segment="${CSS.escape(v)}"]`)?.focus();  };  const sm = size === "sm";  return (    <div      ref={rootRef}      role="radiogroup"      aria-label={label}      onKeyDown={onKeyDown}      className={`relative isolate select-none items-center rounded-full bg-muted p-0.5 shadow-[inset_0_0_0_1px_var(--border)] ${block ? "flex w-full" : "inline-flex"} ${className}`}    >      <span        aria-hidden="true"        className={`pointer-events-none absolute left-0 top-0.5 bottom-0.5 rounded-full bg-background shadow-[0_0_0_1px_var(--border)]`}        style={{          transform: `translate3d(${mark?.x ?? 0}px, 0, 0) scale(${pressing && !reduced ? 0.97 : 1})`,          width: mark?.w ?? 0,          opacity: mark ? 1 : 0,          transition: reduced || !ready ? "none" : `transform 440ms ${EASE_THROW}, width 400ms ${EASE_OUT}, opacity 200ms ${EASE_OUT}`,        }}      />      {options.map((option) => {        const on = option.value === current;        return (          <button            key={option.value}            type="button"            role="radio"            aria-checked={on}            aria-label={option.ariaLabel}            data-segment={option.value}            disabled={option.disabled}            tabIndex={on ? 0 : -1}            onClick={() => pick(option.value)}            onPointerDown={() => on && setPressing(true)}            onPointerUp={() => setPressing(false)}            onPointerLeave={() => setPressing(false)}            className={`relative z-10 inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-full font-medium cursor-pointer transition-colors duration-200 disabled:cursor-default disabled:opacity-40 [&_svg]:size-3.5 ${              sm ? "h-6 px-2.5 text-[12px]" : "h-8 px-3.5 text-[13px]"            } ${block ? "flex-1" : ""} ${on ? "text-foreground" : "text-muted-foreground hover:text-foreground"} ${INTERACTION_FOCUS}`}          >            {option.icon}            {option.label}          </button>        );      })}    </div>  );}export default SegmentedControl;

Registry item: /r/segmented-control.json