Segmented control
One choice from a few with a gliding thumb, in three surfaces, with icons, counts and second lines, upright for side panels and full width on phones. A radio group for the arrow keys.
One choice from a few, at a glance
A segmented control picks one option from a few, with a thumb that glides to the chosen one. It works as an APG radio group, so the arrow keys move and screen readers announce the choice, and every segment reaches 44 px on phones.
View switch toggles list, grid or map for the same results, names the panel it controls and fills the width on phones. With counts shows a count in each segment, tinted on the chosen one, on the quiet underline surface above a list. Billing switch puts the thumb in the accent above a price table, prices count to their new value and the saving appears with the yearly choice. Upright with details stands upright from 1024 px with a second line per option next to the detail and becomes a full width row below. Sizes and surfaces shows three sizes, icons only with names for screen readers and the raised, accent and underline surfaces side by side.
Use a segmented control for two to five options that switch a view right away. For more options or choices that need a submit, a select or a radio group fits better. Keep labels the same length where you can, then the thumb glides evenly.
Variants
Details
What Segmented control brings along, read from the registry and the source.
- Category
- Buttons
- Area
- Elements
- Variants
- 5
- Version
- 1.0.0 · since 1.2.0
- File
- components/ui/segmented-control.tsx
- Brings along
- utilskiticon
- npm packages
- None
- Languages
- Texts come through props
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The SegmentedControlProps interface from components/ui/segmented-control.tsx, with the comments from the code.
optionsrequiredSegmentOption[]valueoptionalstringdefaultValueoptionalstringonValueChangeoptional(value: string) => voidlabelrequiredstringAccessible name of the group, e.g. "View"
sizeoptionalSegmentedSizesmmdlg
appearanceoptionalSegmentedAppearancesurfacesolidunderline
surface: raised thumb on a quiet track. solid: thumb in the accent color. underline: a line slides under the choice.
orientationoptional'horizontal' | 'vertical'stretchoptionalboolean | 'mobile'Fill the width. mobile: only below 640 px, the segments share the width equally there.
controlsoptionalstringid of the panel the control switches, for aria-controls
Accessibility
Read from the source of the component.
- Segmented control handles the keys Arrow up, Arrow down, Arrow left, Arrow right, Home, End and Space itself.
- Sets aria-checked, aria-controls, aria-label and aria-orientation, so screen readers announce purpose and state.
- Uses the roles radio and radiogroup.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- With reduced motion (prefers-reduced-motion) it stays still. 2 places in the code switch motion off then.
- No animations, it stands still from the start.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 243 lines
'use client';
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { cn, useLocale } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
/*
* Segmented control: one value out of two to six, as a single track with a
* thumb that glides to the choice. More than the plain segmented toggle:
* three surfaces (raised, accent, underline), counts that stay readable on
* the thumb, options with a second line, an upright form for side panels and
* a track that fills the width on phones. APG radio group: one tab stop,
* the arrow keys move and choose, Home and End jump to the ends.
*
* Without JavaScript the chosen segment carries the surface itself, so the
* server render already shows the choice.
*/
const useIsoLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect;
export type SegmentedSize = 'sm' | 'md' | 'lg';
export type SegmentedAppearance = 'surface' | 'solid' | 'underline';
export interface SegmentOption {
value: string;
label: string;
icon?: IconName;
/** Small number after the label, e.g. unread items */
count?: number;
/** Show only the icon, the label becomes the accessible name */
iconOnly?: boolean;
/** Second line, for upright controls */
description?: string;
disabled?: boolean;
}
export interface SegmentedControlProps {
options: SegmentOption[];
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
/** Accessible name of the group, e.g. "View" */
label: string;
size?: SegmentedSize;
/** surface: raised thumb on a quiet track. solid: thumb in the accent color. underline: a line slides under the choice. */
appearance?: SegmentedAppearance;
orientation?: 'horizontal' | 'vertical';
/** Fill the width. mobile: only below 640 px, the segments share the width equally there. */
stretch?: boolean | 'mobile';
/** id of the panel the control switches, for aria-controls */
controls?: string;
className?: string;
}
const SIZE: Record<SegmentedSize, { track: string; item: string; square: string; icon: number; text: string; pad: string }> = {
sm: { track: 'rounded-lg p-[3px]', item: 'min-h-[30px] gap-1.5 rounded-[6px] px-2.5 max-sm:min-h-[38px]', square: 'w-[30px] px-0 max-sm:w-[38px]', icon: 15, text: 'text-[13px]', pad: '[--pad:3px]' },
md: { track: 'rounded-[10px] p-1', item: 'min-h-9 gap-2 rounded-[7px] px-3.5', square: 'w-9 px-0', icon: 16, text: 'text-sm', pad: '[--pad:4px]' },
lg: { track: 'rounded-xl p-1', item: 'min-h-11 gap-2 rounded-[9px] px-4', square: 'w-11 px-0', icon: 18, text: 'text-[15px]', pad: '[--pad:4px]' },
};
interface Thumb {
x: number;
y: number;
w: number;
h: number;
animate: boolean;
}
/** A choice of one value from a few, with a gliding thumb. Radio group semantics, arrow keys move and choose. */
export function SegmentedControl({
options,
value,
defaultValue,
onValueChange,
label,
size = 'md',
appearance = 'surface',
orientation = 'horizontal',
stretch,
controls,
className,
}: SegmentedControlProps) {
const locale = useLocale();
const [inner, setInner] = useState(defaultValue ?? options.find((o) => !o.disabled)?.value ?? '');
const current = value ?? inner;
const trackRef = useRef<HTMLDivElement>(null);
const [thumb, setThumb] = useState<Thumb | null>(null);
const vertical = orientation === 'vertical';
const underline = appearance === 'underline';
const s = SIZE[size];
const choose = (v: string) => {
if (value === undefined) setInner(v);
onValueChange?.(v);
};
const measure = useCallback(
(animate: boolean) => {
const el = trackRef.current?.querySelector<HTMLElement>(`[data-value="${CSS.escape(current)}"]`);
if (!el) return setThumb(null);
setThumb({ x: el.offsetLeft, y: el.offsetTop, w: el.offsetWidth, h: el.offsetHeight, animate });
},
[current],
);
const first = useRef(true);
useIsoLayoutEffect(() => {
measure(!first.current);
first.current = false;
}, [measure]);
useEffect(() => {
const track = trackRef.current;
if (!track) return;
const ro = new ResizeObserver(() => measure(false));
ro.observe(track);
// Late web fonts change the widths once more
document.fonts?.ready.then(() => measure(false));
return () => ro.disconnect();
}, [measure]);
const onKeyDown = (e: React.KeyboardEvent) => {
const keys = ['ArrowRight', 'ArrowLeft', 'ArrowDown', 'ArrowUp', 'Home', 'End'];
if (!keys.includes(e.key)) return;
e.preventDefault();
const enabled = options.map((o, i) => (o.disabled ? -1 : i)).filter((i) => i >= 0);
if (!enabled.length) return;
const pos = Math.max(0, enabled.indexOf(options.findIndex((o) => o.value === current)));
const forward = e.key === 'ArrowRight' || e.key === 'ArrowDown';
const next = e.key === 'Home' ? enabled[0] : e.key === 'End' ? enabled[enabled.length - 1] : enabled[(pos + (forward ? 1 : -1) + enabled.length) % enabled.length];
choose(options[next].value);
trackRef.current?.querySelectorAll<HTMLButtonElement>('[role="radio"]')[next]?.focus();
};
const nf = new Intl.NumberFormat(locale === 'de' ? 'de-DE' : 'en-US');
const ready = thumb !== null;
const full = stretch === true;
const phoneFull = stretch === 'mobile';
return (
<div
ref={trackRef}
role="radiogroup"
aria-label={label}
aria-orientation={vertical ? 'vertical' : undefined}
onKeyDown={onKeyDown}
className={cn(
'relative isolate max-w-full',
vertical ? 'flex flex-col items-stretch' : 'inline-flex items-stretch',
!vertical && full && 'flex w-full',
!vertical && phoneFull && 'max-sm:flex max-sm:w-full',
underline
? cn(vertical ? 'gap-0.5 border-s border-border ps-0' : 'gap-1 border-b border-border')
: cn('bg-muted shadow-[inset_0_0_0_1px_var(--border)]', s.track, vertical && 'gap-0.5'),
s.pad,
className,
)}
>
{ready && (
<span
aria-hidden
className={cn(
'pointer-events-none absolute left-0 top-0 -z-10',
appearance === 'surface' && 'rounded-[inherit] bg-background shadow-[0_0_0_1px_var(--border),var(--shadow-sm)] dark:bg-[color-mix(in_oklab,var(--foreground)_11%,var(--background))]',
appearance === 'solid' && 'bg-primary shadow-[inset_0_1px_0_rgb(255_255_255/0.18),var(--shadow-sm)]',
appearance === 'underline' && 'bg-primary',
!underline && (size === 'sm' ? 'rounded-[6px]' : size === 'md' ? 'rounded-[7px]' : 'rounded-[9px]'),
thumb.animate && 'transition-[translate,width,height] duration-[420ms] ease-[cubic-bezier(0.3,1.25,0.5,1)] motion-reduce:transition-none',
)}
style={
underline
? vertical
? { translate: `-1px ${thumb.y}px`, width: 2, height: thumb.h }
: { translate: `${thumb.x}px ${thumb.y + thumb.h - 1}px`, width: thumb.w, height: 2 }
: { translate: `${thumb.x}px ${thumb.y}px`, width: thumb.w, height: thumb.h }
}
/>
)}
{options.map((o) => {
const on = o.value === current;
const iconOnly = o.iconOnly && o.icon;
const onSolid = appearance === 'solid' && on;
return (
<button
key={o.value}
type="button"
role="radio"
aria-checked={on}
aria-label={iconOnly ? o.label : undefined}
aria-controls={controls}
title={iconOnly ? o.label : undefined}
data-value={o.value}
disabled={o.disabled}
tabIndex={on ? 0 : -1}
onClick={() => choose(o.value)}
className={cn(
'relative inline-flex cursor-pointer select-none items-center whitespace-nowrap font-medium outline-none transition-[color,scale,background-color] duration-200 ease-[var(--ease-kit)] active:scale-[0.97] active:duration-100 disabled:pointer-events-none disabled:opacity-45 motion-reduce:active:scale-100',
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-ring',
s.item,
s.text,
// The track padding counts toward the finger target: 36 px segment plus 4 px above and below
!underline && 'after:absolute after:inset-x-0 after:-inset-y-1 after:content-[""]',
vertical ? 'justify-start text-start' : 'justify-center',
iconOnly && !vertical && s.square,
!vertical && (full || phoneFull) && (full ? 'flex-1' : 'max-sm:flex-1'),
underline && (vertical ? 'min-h-11 rounded-none ps-4 max-sm:min-h-11' : 'min-h-11 rounded-none px-3 pb-px max-sm:min-h-11'),
on ? (onSolid ? 'text-primary-foreground' : 'text-foreground') : 'text-muted-foreground hover:text-foreground',
!on && !underline && 'hover:bg-[color-mix(in_oklab,var(--foreground)_4%,transparent)]',
// Until the thumb is measured (server render, no JavaScript) the chosen segment carries the surface itself
on && !ready && appearance === 'surface' && 'bg-background shadow-[0_0_0_1px_var(--border),var(--shadow-sm)]',
on && !ready && appearance === 'solid' && 'bg-primary',
on && !ready && underline && (vertical ? 'shadow-[inset_2px_0_0_var(--primary)]' : 'shadow-[inset_0_-2px_0_var(--primary)]'),
o.description && vertical && 'py-2.5',
)}
>
{o.icon && <Icon name={o.icon} size={s.icon} className={cn('shrink-0', !on && !onSolid && 'opacity-80', o.description && vertical && 'self-start mt-0.5')} />}
{!iconOnly &&
(o.description && vertical ? (
<span className="min-w-0 flex-1 whitespace-normal">
<span className="block leading-5">{o.label}</span>
<span className={cn('mt-0.5 block text-[12px] font-normal leading-4', onSolid ? 'text-primary-foreground/80' : 'text-muted-foreground')}>{o.description}</span>
</span>
) : (
<span className={cn(vertical && 'flex-1')}>{o.label}</span>
))}
{o.count !== undefined && (
<span
className={cn(
'inline-grid h-5 min-w-5 shrink-0 place-items-center rounded-md px-1.5 text-[11px] font-semibold tabular-nums leading-none transition-[background-color,color] duration-200',
onSolid
? 'bg-[color-mix(in_oklab,var(--primary-foreground)_22%,transparent)] text-primary-foreground'
: on
? 'bg-[color-mix(in_oklab,var(--primary)_16%,transparent)] text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]'
: 'bg-[color-mix(in_oklab,var(--foreground)_8%,transparent)] text-muted-foreground',
)}
>
{nf.format(o.count)}
</span>
)}
</button>
);
})}
</div>
);
}Frequent questions
How do I install Segmented control?
With aiw add @ai-website/segmented-control. The command puts components/ui/segmented-control.tsx into your project, the code is yours from then on. It brings along icon. It needs no further npm packages.
Which variants does Segmented control have?
5 variants, View switch, With counts, Billing switch, Upright with details and Sizes and surfaces.
Can an AI assistant build with Segmented control?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Segmented control with aiw_components and install it with aiw_add @ai-website/segmented-control.