Segmented Control
A compact in-place switch for choosing one of a small set of options, drawn as one track of options with a sliding thumb.
Installation
Usage
import { SegmentedControl } from "@/components/ui/segmented-control"Examples
Default
A two-option value switch. Arrow keys move the selection with wrap-around, so the group always reports the option the user is on.
Sizes
The same heights a Button or Input of the same size name measures, 28 / 32 / 40, so the control sits level with the button beside it in a header or title row. Corners come from the action ramp for the same reason.
With icons
Give an option an icon to draw it before the label. iconOnly drops the labels for a toolbar-width control and keeps each one as the option's accessible name and hover title, so a bare control is never a nameless one.
Over media
tone="onMedia" swaps the token palette for fixed black and white plus a backdrop blur, because no semantic color can be trusted over a photo. Only use it on media; on a normal surface it reads as a foreign element.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
| value | string | - | The selected option's value. Controlled: pair with onValueChange. |
| onValueChange | (value: string) => void | - | Called with the newly selected value, from a click or an arrow key. |
| options | SegmentedOption[] | - | The choices, as { value, label, icon? }. Order is the render order and the arrow-key order. An icon renders before the label, or alone under iconOnly. |
| iconOnly | boolean | false | Draw the icons alone and drop the visible labels. Each label stays as that option's accessible name and hover title. Group-level, not per option: a row of peers where some are labelled and some are not is a defect, not a layout. |
| size | "sm" | "default" | "lg" | "default" | Widget height on the kit's control ramp: 28 / 32 / 40px, the same as a Button of the same size name. The options sit inside a 2px inset and a 1px border. |
| tone | "default" | "onMedia" | "default" | default is a neutral track with an accent thumb, the same paint as Tabs; onMedia is fixed black and white with a backdrop blur, for sitting over a photo or video. |
| aria-label | string | - | Required. The control is a radiogroup and has no visible label of its own, so it needs a name. |
"use client"
import * as React from "react"
import { useEffect, useLayoutEffect, useRef, useState } from "react"
import { motion, useReducedMotion } from "motion/react"
import { cn } from "@/registry/lib/utils"
/* ─────────────────────────────────────────────────────────
* ANIMATION: SLIDING THUMB
*
* ONE persistent indicator lives on the track and is moved to
* the measured box of the selected option, which is the same
* mechanism Tabs and Nav use. It is deliberately NOT a
* per-option `layoutId` shared element: that mounts a new
* thumb and unmounts the old one on every switch, and Motion's
* hand-off paints both boxes at once, so a long slide reads as
* one lozenge stretched across two or three segments before it
* snaps onto the target. Dropping `AnimatePresence` reduces
* that without removing it, because the mount/unmount hand-off
* IS the bug. See tabs.tsx for the full write-up; the artifact
* is worst here, since a segmented control is short enough
* that a two-segment slide crosses most of the widget.
*
* `width` is animated, the same deliberate exception Tabs
* makes: the thumb is absolutely positioned with no children,
* so its layout is contained to itself and never reflows a
* sibling, and scaleX would distort its corner arcs.
* ───────────────────────────────────────────────────────── */
const THUMB = {
spring: { type: "spring" as const, visualDuration: 0.15, bounce: 0 },
}
// useLayoutEffect warns during SSR; fall back to useEffect there.
const useIsomorphicLayoutEffect =
typeof window !== "undefined" ? useLayoutEffect : useEffect
export type SegmentedOption = {
value: string
label: string
/** Rendered before the label, or alone when the control is `iconOnly`. */
icon?: React.ReactNode
}
type SegmentedTone = "default" | "onMedia"
type SegmentedSize = "sm" | "default" | "lg"
// The WIDGET is the rung, 28 / 32 / 40, the same heights a Button or an Input
// of the same size name measures. That is the fit this control has to make:
// every call site puts it on a row with a Button (a page header's action slot,
// a title row's centre), never on a row with a Tabs strip. It used to put the
// rung on the OPTION and let the track add `p-1` plus a border on top, which
// is what Tabs does, so `sm` measured 38 next to a 28px `sm` Button and a
// `default` 42 stood taller than a `lg` anything. Tabs can size its trigger to
// the rung because a tab strip owns its own row; this control shares one.
//
// So the option is the rung less the track's inset: `p-0.5` and a 1px border
// on each side take 6, leaving 22 / 26 / 34. Off the 4px grid, and that is
// fine: the grid is for the gaps between things, and this is an interior box
// nothing else lines up against. Type still follows the WIDGET height, so a
// 32px segmented control carries the same 13px a 32px Button does.
//
// The svg size is scoped with `:not` so a caller can override one icon with
// its own `size-*` instead of losing to this rule.
//
// Radius comes from the action ramp, the same rule Button reads: the track is
// `rounded-action-sm` / `rounded-action` / `rounded-action-lg` at its own
// height, and the option is that less the 3px gutter so the two arcs stay
// concentric. 12 less 3 is `rounded-action-xs` exactly and 15 less 3 is
// `rounded-action` exactly; `sm` wants 7.5 and takes `rounded-md` (8), which is
// as close as the ladder comes and is not a seam anyone can see at that size.
// It was `rounded-full` and that is exactly what made it read as a foreign
// object: the kit's only full-round things are Badge and Switch, both
// indicators, so a pill row beside an action-ramp Button was a second shape
// family in one toolbar. It also forced the icon-only options to be squares
// (an oblong in a row of circles reads as an oval), so that rule is gone too:
// icon-only options take the same tight padding a Tabs trigger does.
const OPTION_SIZE: Record<
SegmentedSize,
{ track: string; option: string; shared: string; text: string; iconOnly: string; gap: string }
> = {
sm: {
track: "rounded-action-sm",
option: "rounded-md",
shared: "h-5.5 pointer-coarse:min-h-6.5 text-1xs [&_svg:not([class*='size-'])]:size-3",
text: "px-2",
iconOnly: "px-1.5",
gap: "gap-1.5",
},
default: {
track: "rounded-action",
option: "rounded-action-xs",
shared: "h-6.5 text-xs [&_svg:not([class*='size-'])]:size-3.5",
text: "px-3",
iconOnly: "px-1.5",
gap: "gap-2",
},
lg: {
track: "rounded-action-lg",
option: "rounded-action",
shared: "h-8.5 text-sm [&_svg:not([class*='size-'])]:size-4",
text: "px-3",
iconOnly: "px-2",
gap: "gap-2",
},
}
// The thumb is Tabs' indicator: an accent tint under an accent label, flat, no
// shadow. Tabs and this are the kit's two one-of-N strips, and they used to
// paint the selected state two different ways, an accent tint there and a
// raised white thumb with a drop shadow here. The raised thumb is the iOS
// answer, and it was the loudest thing on a page where nothing else carries a
// shadow except a Card. Now the selected option reads the way a selected tab,
// a `soft` Button and a checked Checkbox already do, and the track is the same
// neutral well a `TabsList` sits in, so the two are one object with a radius
// apart.
const TONE: Record<
SegmentedTone,
{ track: string; thumb: string; active: string; inactive: string }
> = {
default: {
track: "bg-neutral-50 dark:bg-neutral-900 border border-neutral-500/30",
thumb: "bg-accent-100 dark:bg-accent-800",
active: "text-accent dark:text-accent-400",
inactive: "text-muted-foreground hover:text-foreground",
},
onMedia: {
// Over a photo or video there is no token that can be trusted, so this tone
// is deliberately absolute: fixed black and white, never semantic. The
// inactive colour is white at 75% rather than `text-foreground`, which is
// near-black in light mode and would vanish against the media.
track: "bg-black/30 backdrop-blur-sm border border-white/20",
thumb: "bg-white",
active: "text-neutral-950",
inactive: "text-white/75 hover:text-white",
},
}
/**
* A compact in-place switch for choosing one of a small set of options, drawn
* as a row of options in one track with a sliding thumb.
*
* It is NOT Tabs and NOT Switch. Tabs swaps a content panel and owns the region
* below it; Switch is a boolean. This is a labelled one-of-N selector that
* changes a value in place (`in`/`cm`, `Grid`/`List`, `Monthly`/`Yearly`), so it
* is a `radiogroup` rather than a tablist and carries no panel of its own.
*
* Keep the option count small. Every option is painted at once, so the widget
* grows with the list; past four or five, use a Select.
*/
// @use-when a labelled one-of-N switch that changes a value in place (in/cm,
// Grid/List, Monthly/Yearly). Not Tabs, which swaps a content panel, and not
// Switch, which is a boolean.
export function SegmentedControl({
value,
onValueChange,
options,
tone = "default",
size = "default",
iconOnly = false,
className,
"aria-label": ariaLabel,
...props
}: Omit<React.ComponentProps<"div">, "onChange" | "role"> & {
value: string
onValueChange: (value: string) => void
options: SegmentedOption[]
tone?: SegmentedTone
size?: SegmentedSize
/**
* Draw the icons alone and drop the visible labels. `label` is still
* required and becomes each option's accessible name, so a text-less control
* is never a nameless one.
*
* A group-level prop rather than a per-option one, unlike Tabs, which derives
* it from a trigger having no children. A segmented control is read as one
* row of peers, so a mix of labelled and bare options is a defect rather than
* a layout to support.
*/
iconOnly?: boolean
"aria-label": string
}) {
const t = TONE[tone]
const trackRef = useRef<HTMLDivElement>(null)
const [thumb, setThumb] = useState<{
x: number
y: number
width: number
height: number
} | null>(null)
const shouldReduceMotion = useReducedMotion()
useIsomorphicLayoutEffect(() => {
const track = trackRef.current
if (!track) return
const measure = () => {
// Match on the option's own value rather than a checked attribute, so a
// measurement never races the render that flips `aria-checked`.
const active = Array.from(
track.querySelectorAll<HTMLElement>('[data-slot="segmented-control-option"]')
).find((el) => el.dataset.value === value)
// Read the full box including offsetTop/offsetHeight: an absolute child is
// positioned against the track's PADDING box while the option sits inside
// the track's `p-1`, so deriving the vertical box from the option keeps the
// thumb inset correctly whatever that padding becomes.
setThumb(
active
? {
x: active.offsetLeft,
y: active.offsetTop,
width: active.offsetWidth,
height: active.offsetHeight,
}
: null
)
}
measure()
// Options resize on font load, container resize, and label changes.
const observer = new ResizeObserver(measure)
observer.observe(track)
track
.querySelectorAll('[data-slot="segmented-control-option"]')
.forEach((el) => observer.observe(el))
return () => observer.disconnect()
}, [value, options])
// Roving arrow keys with wrap-around. Selection MOVES with the arrow rather
// than only focus, which is the radiogroup pattern: a radio group reports one
// value, so an arrow that focused without selecting would leave the group
// announcing a different option than the one the user is on.
const move = (dir: 1 | -1) => {
const i = options.findIndex((o) => o.value === value)
if (i === -1) return
const next = (i + dir + options.length) % options.length
onValueChange(options[next].value)
}
return (
<div
ref={trackRef}
role="radiogroup"
aria-label={ariaLabel}
data-slot="segmented-control"
data-tone={tone}
data-size={size}
onKeyDown={(event) => {
if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
event.preventDefault()
move(-1)
} else if (event.key === "ArrowRight" || event.key === "ArrowDown") {
event.preventDefault()
move(1)
}
}}
// `relative` anchors the thumb: an option's offsetLeft and an absolute
// child's left:0 both resolve to this element's padding box.
className={cn(
"relative inline-flex w-fit items-center p-0.5",
OPTION_SIZE[size].track,
t.track,
className
)}
{...props}
>
{/* Rendered before the options so they paint over it. */}
{thumb && (
<motion.span
aria-hidden
data-slot="segmented-control-thumb"
className={cn(
"pointer-events-none absolute top-0 left-0",
// The thumb IS the selected option's box, so it takes that radius.
OPTION_SIZE[size].option,
t.thumb
)}
// Height is set, not animated: every option shares one height from the
// size rung, so it is identical for all of them.
style={{ height: thumb.height }}
// initial={false}: adopt the first measurement instead of sliding in
// from x:0/width:0 on mount.
initial={false}
animate={{ x: thumb.x, y: thumb.y, width: thumb.width }}
transition={shouldReduceMotion ? { duration: 0 } : THUMB.spring}
/>
)}
{options.map((option) => {
const active = option.value === value
// Icons alone need BOTH: `aria-label` names the option for assistive
// tech, `title` names it for a sighted pointer user, who otherwise has
// three unlabelled glyphs and a guess.
const bare = iconOnly && option.icon != null
return (
<button
key={option.value}
type="button"
role="radio"
aria-checked={active}
aria-label={bare ? option.label : undefined}
title={bare ? option.label : undefined}
// Roving tabindex: one stop for the whole group, so Tab moves past
// the control rather than through every option.
tabIndex={active ? 0 : -1}
data-slot="segmented-control-option"
data-value={option.value}
onClick={() => onValueChange(option.value)}
className={cn(
// No `motion-reduce:transition-none` on the colour fade: a colour
// change is non-vestibular and aids comprehension, so reduced
// motion keeps it. The press scale IS vestibular, so it goes.
"relative z-10 inline-flex cursor-pointer items-center justify-center font-medium whitespace-nowrap transition-[color] duration-150 [&_svg]:shrink-0",
"focus-visible:ring-ring-focus focus-visible:outline-ring focus-visible:ring-[3px] focus-visible:outline-1",
"active:scale-[0.98] motion-reduce:active:scale-100",
OPTION_SIZE[size].option,
OPTION_SIZE[size].shared,
bare ? OPTION_SIZE[size].iconOnly : OPTION_SIZE[size].text,
option.icon != null && !bare && OPTION_SIZE[size].gap,
active ? t.active : t.inactive
)}
>
{option.icon}
{bare ? null : option.label}
</button>
)
})}
</div>
)
}