极简标签页

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

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

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

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

复用卡

Tabs (APG, automatic activation) · 极简标签页 · 标签页 / Minimal

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

做一个 Tabs (APG, automatic activation)(极简标签页):
- tablist / tab / tabpanel 三件套,aria-selected + aria-controls 绑定面板
- roving tabindex:只有当前页签进 Tab 顺序
- ←→↑↓ 同时移动焦点与选中项,Home / End 到两端,禁用项跳过
- 发丝基线 + accent 下划线;未选中项悬停只浮出凹陷底色
用这些 CSS 变量:--color-accent --color-divider --color-text --color-text-muted --dur-fast --ease-standard --glass-recess
灵感参考:APG Tabs pattern (automatic activation) + macOS segmented tabs

参数(CSS 变量)

--color-accent --color-divider --color-text --color-text-muted --dur-fast --ease-standard --glass-recess

必缺状态

焦点 / 按压 / 禁用

整页场景

(尚未接进整页场景)

安装(一条链接)

调研笔记

极简标签页

拆解

  • 与玻璃版同一套 APG Tabs:tablist / tab / tabpanel 三件套,aria-selected + aria-controls
  • roving tabindex:当前页签 tabIndex=0,其余 -1;Tab 进出整组只停一次
  • 方向键同时切换焦点与选中项,Home / End 到两端,禁用页签跳过
  • 面板 tabIndex=0;下划线 scaleX 展开,不测量宽度

材质

  • 页签条:白底 + 发丝基线,没有模糊与高光
  • 未选中项悬停只浮出 --glass-recess;当前页签 accent 文字 + 2px 下划线
  • 圆角收在 rounded-t-md,与极简输入框同一档

边界

  • 只渲染当前面板;不做懒加载
  • 自动激活只适合即时可读的面板;远程数据应改手动激活
  • 页签 ≤6,再多换用其他导航形态

变量:--color-divider / --glass-recess / --color-accent / --color-text-muted。

实现

tabs/minimal
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 MinimalTabs({
  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 border-b border-[var(--color-divider)]"
      >
        {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-md 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-recess)]'}
              `}
            >
              {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/minimal
状态
done
最后提交
2026-10-31
风格轴
minimal
依赖 token
--color-accent --color-divider --color-text --color-text-muted --dur-fast --ease-standard --glass-recess
在工作台打开 源码与 notes 都在 src/components/tabs/minimal/