segmented · glass-apple · 灵感来源:iOS UISegmentedControl / 控制中心切换器
统计范围:本周 · 试试 ← → 方向键;「月」是禁用项
外观:跟随系统 · sm 尺寸 + emoji 标签
Segmented Control (iOS) · 分段控件 · 分段 / Glass
做一个 Segmented Control (iOS)(分段控件):
- APG radiogroup:方向键遍历 + roving tabindex
- 滑块用独立 translate 平移,不动其他属性
- 选中切换时滑块做一次 squash & stretch
- 选项可单独禁用,禁用项不响应方向键
用这些 CSS 变量:--color-surface --color-text --color-text-muted --color-text-subtle --dur-base --dur-fast --ease-standard --fw-medium --fw-normal --glass-border --glass-recess --shadow-sm --text-sm --text-xs
灵感参考:iOS UISegmentedControl / 控制中心切换器--color-surface --color-text --color-text-muted --color-text-subtle --dur-base --dur-fast --ease-standard --fw-medium --fw-normal --glass-border --glass-recess --shadow-sm --text-sm --text-xs
焦点 / 禁用
/r/segmented--glass-apple.json
免外壳的 markdown 版:/llms/components/segmented--glass-apple.md--glass-recess),选中项是凸起的白色滑块--dur-base(250ms),曲线 --ease-standard,快出缓收接近弹簧感repeat(n, 1fr);选中文字转主色加重width = (100% - 2×内边距) / n + translate: i×100%(百分比相对自身宽度,天然对齐)translate / scale),两个动画互不干扰变量:--glass-recess(ink 5%)/ 滑块 --color-surface + --shadow-sm。
import { useRef, useState, type KeyboardEvent, type ReactNode } from 'react';
export interface SegmentOption {
value: string;
label: ReactNode;
/** 禁用项:不可选也不响应方向键 */
disabled?: boolean;
}
interface Props {
options: SegmentOption[];
value: string;
onChange?: (value: string) => void;
size?: 'sm' | 'md';
className?: string;
}
const sizeClasses = {
sm: 'h-8 text-[length:var(--text-xs)] px-2.5',
md: 'h-9 text-[length:var(--text-sm)] px-3.5',
};
export function GlassAppleSegmented({
options,
value,
onChange,
size = 'md',
className = '',
}: Props) {
const index = Math.max(0, options.findIndex((o) => o.value === value));
const [stretching, setStretching] = useState(false);
const btnRefs = useRef<(HTMLButtonElement | null)[]>([]);
const select = (next: number, focus = false) => {
const clamped = Math.max(0, Math.min(options.length - 1, next));
if (options[clamped]?.disabled) return;
if (clamped !== index) {
setStretching(true);
window.setTimeout(() => setStretching(false), 280);
}
onChange?.(options[clamped].value);
if (focus) btnRefs.current[clamped]?.focus();
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
e.preventDefault();
select(index + 1, true);
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
e.preventDefault();
select(index - 1, true);
} else if (e.key === 'Home') {
e.preventDefault();
select(0, true);
} else if (e.key === 'End') {
e.preventDefault();
select(options.length - 1, true);
}
};
return (
<div
role="radiogroup"
onKeyDown={onKeyDown}
className={`
relative grid items-stretch
rounded-full p-1
bg-[var(--glass-recess)] border border-[var(--glass-border)]
${className}
`}
style={{ gridTemplateColumns: `repeat(${options.length}, 1fr)` }}
>
{/* 滑块:绝对定位 + translate 平移,宽度按分格计算 */}
<span
aria-hidden="true"
className={`
absolute top-1 bottom-1 left-1 rounded-full
bg-[var(--color-surface)] border border-[var(--glass-border)]
shadow-[var(--shadow-sm)]
${stretching ? 'segmented-stretch' : ''}
`}
style={{
width: `calc((100% - 8px) / ${options.length})`,
translate: `${index * 100}% 0`,
transition: 'translate var(--dur-base) var(--ease-standard)',
}}
/>
{options.map((opt, i) => {
const selected = i === index;
return (
<button
key={opt.value}
ref={(el) => { btnRefs.current[i] = el; }}
type="button"
role="radio"
aria-checked={selected}
tabIndex={selected ? 0 : -1}
onClick={() => select(i)}
disabled={opt.disabled}
aria-disabled={opt.disabled}
className={`
relative z-[1] rounded-full whitespace-nowrap
${sizeClasses[size]}
transition-colors duration-[var(--dur-fast)]
select-none
${opt.disabled
? 'text-[var(--color-text-subtle)] cursor-not-allowed opacity-50'
: `cursor-pointer ${selected
? 'text-[var(--color-text)]'
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text)]'}`}
`}
style={{ fontWeight: selected ? 'var(--fw-medium)' : 'var(--fw-normal)' }}
>
{opt.label}
</button>
);
})}
</div>
);
}
src/components/segmented/glass-apple/