玻璃标签页

tabs · glass-apple · 灵感来源:APG Tabs pattern (automatic activation) + macOS segmented tabs

3 个组件待复核,最近更新 2026-10-31。

试试 ← → / Home / End;「设置」是禁用项。

当前页签:overview · 方向键切换,焦点与选中同步

复用卡

Tabs (APG, automatic activation) · 玻璃标签页 · 标签页 / Glass

规格 · 术语提示词(给 AI 用)

做一个 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

参数(CSS 变量)

--color-accent --color-text --color-text-muted --dur-fast --ease-standard --glass-bg --glass-border --glass-inset

必缺状态

焦点 / 按压 / 禁用

整页场景

(尚未接进整页场景)

安装(一条链接)

调研笔记

玻璃标签页

拆解

  • role="tablist" + role="tab" + role="tabpanel";aria-selected / aria-controls / aria-labelledby 把页签与面板绑在一起
  • roving tabindex:只有当前页签是 tabIndex=0,Tab 进出整组只停一次
  • 方向键(←→↑↓)同时移动焦点与选中项(automatic activation);Home / End 到两端;禁用页签跳过
  • 面板 tabIndex=0,内容没有可聚焦元素时键盘也能滚读;下划线用 scaleX 展开,不量宽度

材质

  • 页签条:一条玻璃发丝线做基线;未选中项悬停时浮出 --glass-bg 底
  • 当前页签:accent 文字 + 2px accent 下划线;下划线从左侧展开
  • 面板区不带卡片,内容直接落在页面底上——避免和演示容器套两层卡

边界

  • 一次只渲染当前面板(children),不做懒加载与预取
  • 自动激活适合内容即时可读的面板;如果要等远程数据,应改成手动激活(方向键只移焦点)
  • 页签数量建议 ≤6;超出应换分段或侧栏

变量:--glass-border / --glass-bg / --color-accent / --color-text-muted。

实现

tabs/glass-apple
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>
  );
}

元信息

标识
tabs/glass-apple
状态
done
最后提交
2026-10-31
风格轴
glass-apple
依赖 token
--color-accent --color-text --color-text-muted --dur-fast --ease-standard --glass-bg --glass-border --glass-inset
在工作台打开 源码与 notes 都在 src/components/tabs/glass-apple/