Installation
Run this in a project set up with shadcn/ui:
$ pnpm dlx shadcn@latest add https://uxelith.com/r/segmented-control.jsonIt adds these packages for you:
clsxtailwind-mergemotionThe source lands in your components folder as
segmented-control.tsx. It's yours: change anything.
Usage
"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.
"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
| Prop | Type | Default | Description |
|---|---|---|---|
| options | { value: string; label: ReactNode; icon?: ReactNode; disabled?: boolean }[] | — | The choices, in order. |
| label | string | — | Accessible name for the group, e.g. "View". |
| value / defaultValue | string | — / first enabled | Controlled or initial selection. |
| onValueChange | (value: string) => void | — | Called with the new selection. |
| size | "sm" | "md" | "md" | Segment height and text size. |
| fullWidth | boolean | false | Stretch segments to fill the row. |
| name | string | — | 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
- v1.0.0Latest
First release.