分段控件

segmented · glass-apple · 灵感来源:iOS UISegmentedControl / 控制中心切换器

统计范围:本周 · 试试 ← → 方向键;「月」是禁用项

外观:跟随系统 · sm 尺寸 + emoji 标签

复用卡

Segmented Control (iOS) · 分段控件 · 分段 / Glass

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

做一个 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 / 控制中心切换器

参数(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

必缺状态

焦点 / 禁用

安装(一条链接)

调研笔记

分段控件

拆解

  • 轨道"凹陷":比背景暗一档的槽(--glass-recess),选中项是凸起的白色滑块
  • 滑块平移 --dur-base(250ms),曲线 --ease-standard,快出缓收接近弹簧感
  • 滑动途中挤压拉伸(squash & stretch):沿运动方向拉长、垂直压扁,到位回弹
  • 等宽分格:Grid repeat(n, 1fr);选中文字转主色加重
  • radiogroup 模式:← → / Home / End 移动选择,roving tabindex

实现取舍

  • 滑块定位零测量:width = (100% - 2×内边距) / n + translate: i×100%(百分比相对自身宽度,天然对齐)
  • 平移与拉伸用独立 transform 属性(translate / scale),两个动画互不干扰
  • 拉伸做了对称简化:真 iOS 的拉伸方向跟随运动方向(前缘先走),v1 用中心对称挤压,观感已接近
  • font-weight 不做过渡(静态字重无法平滑插值),只做颜色切换

变量:--glass-recess(ink 5%)/ 滑块 --color-surface + --shadow-sm。

实现

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

元信息

标识
segmented/glass-apple
状态
done
最后提交
2026-10-31
风格轴
glass-apple
依赖 token
--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
在工作台打开 源码与 notes 都在 src/components/segmented/glass-apple/