tabs · glass-apple · 灵感来源:APG Tabs pattern (automatic activation) + macOS segmented tabs
3 个组件待复核,最近更新 2026-10-31。
试试 ← → / Home / End;「设置」是禁用项。
当前页签:overview · 方向键切换,焦点与选中同步
Tabs (APG, automatic activation) · 玻璃标签页 · 标签页 / Glass
做一个 Tabs (APG, automatic activation)(玻璃标签页):
- tablist / tab / tabpanel 三件套,aria-selected + aria-controls 绑定面板
- roving tabindex:只有当前页签进 Tab 顺序
- ←→↑↓ 同时移动焦点与选中项,Home / End 到两端,禁用项跳过
- 当前页签 accent 文字 + 2px 下划线,下划线用 scaleX 展开
用这些 CSS 变量:--color-accent --color-text --color-text-muted --dur-fast --ease-standard --glass-bg --glass-border --glass-inset
灵感参考:APG Tabs pattern (automatic activation) + macOS segmented tabs--color-accent --color-text --color-text-muted --dur-fast --ease-standard --glass-bg --glass-border --glass-inset
焦点 / 按压 / 禁用
(尚未接进整页场景)
/r/tabs--glass-apple.json
免外壳的 markdown 版:/llms/components/tabs--glass-apple.mdrole="tablist" + role="tab" + role="tabpanel";aria-selected / aria-controls /
aria-labelledby 把页签与面板绑在一起tabIndex=0,Tab 进出整组只停一次tabIndex=0,内容没有可聚焦元素时键盘也能滚读;下划线用 scaleX 展开,不量宽度--glass-bg 底children),不做懒加载与预取变量:--glass-border / --glass-bg / --color-accent / --color-text-muted。
import { useId, useRef, type KeyboardEvent, type ReactNode } from 'react';
export interface TabItem {
value: string;
label: string;
disabled?: boolean;
}
interface Props {
tabs: TabItem[];
value: string;
onChange?: (value: string) => void;
label?: string;
children?: ReactNode;
className?: string;
}
/**
* 玻璃标签页:APG Tabs(automatic activation)。
* 方向键同时移动焦点与选中项;roving tabindex 只让当前页签进 Tab 顺序。
*/
export function GlassAppleTabs({
tabs,
value,
onChange,
label = '标签页',
children,
className = '',
}: Props) {
const baseId = useId();
const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
const activeIndex = Math.max(0, tabs.findIndex((t) => t.value === value));
const panelId = `${baseId}-panel`;
const firstEnabled = () => tabs.findIndex((t) => !t.disabled);
const lastEnabled = () => {
for (let i = tabs.length - 1; i >= 0; i--) if (!tabs[i].disabled) return i;
return -1;
};
const select = (index: number) => {
const tab = tabs[index];
if (!tab || tab.disabled) return;
onChange?.(tab.value);
tabRefs.current[index]?.focus();
};
const move = (dir: 1 | -1) => {
// 从当前焦点页签出发,而不是仅从选中项出发;正常使用时两者一致
const focused = tabRefs.current.findIndex((el) => el === document.activeElement);
let next = focused >= 0 ? focused : activeIndex;
for (let i = 0; i < tabs.length; i++) {
next = (next + dir + tabs.length) % tabs.length;
if (!tabs[next]?.disabled) break;
}
select(next);
};
const onKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
e.preventDefault();
move(1);
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
e.preventDefault();
move(-1);
} else if (e.key === 'Home') {
e.preventDefault();
select(firstEnabled());
} else if (e.key === 'End') {
e.preventDefault();
select(lastEnabled());
}
};
return (
<div className={className}>
<div
role="tablist"
aria-label={label}
className="
flex items-center gap-1 px-1.5 pt-1.5 rounded-t-xl
bg-[var(--glass-bg)] backdrop-blur-xl
border-b border-[var(--glass-border)] shadow-[var(--glass-inset)]
"
>
{tabs.map((tab, index) => {
const isActive = index === activeIndex;
return (
<button
key={tab.value}
ref={(el) => {
tabRefs.current[index] = el;
}}
id={`${baseId}-tab-${index}`}
type="button"
role="tab"
aria-selected={isActive}
aria-controls={panelId}
tabIndex={isActive ? 0 : -1}
disabled={tab.disabled}
onClick={() => {
if (!tab.disabled) onChange?.(tab.value);
}}
onKeyDown={onKeyDown}
className={`
relative px-3.5 py-2 rounded-t-lg text-sm text-left
press-feedback
transition-colors duration-[var(--dur-fast)]
disabled:opacity-40 disabled:cursor-not-allowed
${isActive
? 'text-[var(--color-accent)]'
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text)] hover:bg-[var(--glass-bg)]'}
`}
>
{tab.label}
<span
aria-hidden="true"
className={`
pointer-events-none absolute left-2.5 right-2.5 -bottom-px h-0.5 rounded-full
bg-[var(--color-accent)] origin-left
transition-transform duration-[var(--dur-fast)] ease-[var(--ease-standard)]
${isActive ? 'scale-x-100' : 'scale-x-0'}
`}
/>
</button>
);
})}
</div>
<div
id={panelId}
role="tabpanel"
aria-labelledby={`${baseId}-tab-${activeIndex}`}
tabIndex={0}
className="pt-4 rounded-md text-sm text-[var(--color-text)]"
>
{children}
</div>
</div>
);
}
src/components/tabs/glass-apple/