Segmented Control
A pill track with a SNAPPY spring thumb.
'use client';
import { useState } from 'react';
import { Rows, SquaresFour, ListBullets } from '@phosphor-icons/react';
import { Segmented } from '@/components/ui/segmented';
export default function SegmentedDemo() {
const [view, setView] = useState<'grid' | 'list' | 'rows'>('grid');
const [range, setRange] = useState<'d' | 'w' | 'm'>('w');
return (
<div className="flex flex-col items-center gap-4">
<Segmented
ariaLabel="View"
value={view}
onChange={setView}
options={[
{ value: 'grid', label: 'Grid', icon: <SquaresFour /> },
{ value: 'list', label: 'List', icon: <ListBullets /> },
{ value: 'rows', label: 'Rows', icon: <Rows /> },
]}
/>
<Segmented
ariaLabel="Range"
value={range}
onChange={setRange}
options={[
{ value: 'd', label: 'Today' },
{ value: 'w', label: 'This week' },
{ value: 'm', label: 'This month' },
]}
/>
</div>
);
}
Installation
'use client';
import { motion } from 'motion/react';
import { useId, type ReactNode } from 'react';
import { SPRING_SNAPPY } from '@/lib/motion';
export interface SegmentOption<T extends string> {
value: T;
label: ReactNode;
icon?: ReactNode;
}
/** 胶囊轨道 + 弹簧滑块(2px 内边距,999px 圆角) */
export function Segmented<T extends string>({
value,
onChange,
options,
ariaLabel,
}: {
value: T;
onChange: (v: T) => void;
options: SegmentOption<T>[];
ariaLabel: string;
}) {
const id = useId();
return (
<div role="radiogroup" aria-label={ariaLabel} className="inline-flex rounded-full bg-ghost-selected p-[2px]">
{options.map((o) => {
const active = o.value === value;
return (
<button
key={o.value}
role="radio"
aria-checked={active}
onClick={() => onChange(o.value)}
className={
'cds-focus relative inline-flex h-7 items-center gap-1.5 rounded-full px-3 text-[13px] font-medium transition-colors duration-[120ms] [&_svg]:size-4 ' +
(active ? 'text-text-primary' : 'text-text-secondary hover:text-text-primary')
}
>
{active && (
<motion.span
layoutId={`seg-${id}`}
transition={SPRING_SNAPPY}
className="absolute inset-0 rounded-full bg-surface-2 shadow-[0_0_0_0.5px_var(--cds-border-strong),0_1px_2px_rgba(0,0,0,0.08)] dark:bg-surface-popover"
/>
)}
<span className="relative z-10 inline-flex items-center gap-1.5">
{o.icon}
{o.label}
</span>
</button>
);
})}
</div>
);
}
Usage
<Segmented ariaLabel="View" value={v} onChange={setV}
options={[{ value: "grid", label: "Grid" }, { value: "list", label: "List" }]} />