pic
<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Apple-style Capsule Button</title>
  <style>
    :root { color-scheme: dark; font: 100%/1.5 -apple-system, BlinkMacSystemFont, "SF Pro Text", "PingFang SC", "Helvetica Neue", sans-serif; }
    * { box-sizing: border-box; }
    body { min-height: 100vh; margin: 0; display: grid; place-items: center; padding: 24px; background: #050505; color: #f5f5f7; }
    .demo { width: min(100%, 920px); display: grid; justify-items: center; }
    .capsule {
      --inset: 6px;
      position: relative; isolation: isolate; display: grid; grid-template-columns: 1fr 1fr;
      width: min(100%, 760px); min-height: clamp(76px, 11vw, 130px); padding: var(--inset);
      border: 0; border-radius: 999px; background: #1b1b1b; box-shadow: none;
      touch-action: pan-y; user-select: none;
    }
    .thumb-base, .thumb-front { position: absolute; z-index: 0; left: var(--inset); width: calc(50% - var(--inset)); border-radius: 999px; transform: translate3d(0,0,0); will-change: transform; pointer-events: none; }
    .thumb-base { top: var(--inset); bottom: var(--inset); background: #4a4a4a; border: 2px solid #6b6b6b; }
    .thumb-front { top: calc(var(--inset) + 2px); bottom: var(--inset); background: rgba(74,74,74,.95); }
    .option { position: relative; z-index: 1; min-width: 0; border: 0; border-radius: 999px; padding: 0 18px; background: transparent; color: rgba(245,245,247,.52); font: 600 clamp(1.35rem, 4vw, 3.25rem)/1 system-ui, sans-serif; letter-spacing: 0; cursor: pointer; transition: color 180ms ease, transform 120ms ease; }
    .option:hover { color: rgba(255,255,255,.82); }
    .option:active, .option.is-pressed { transform: scale(.975); }
    .option[aria-selected="true"] { color: #f5f5f7; text-shadow: 0 1px 2px rgba(0,0,0,.25); }
    .option:focus-visible { outline: 3px solid rgba(255,255,255,.9); outline-offset: 4px; }
    .status { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
    @media (max-width: 520px) { .capsule { --inset: 4px; min-height: 64px; } .option { padding: 0 10px; } }
    @media (prefers-reduced-motion: reduce) { .option { transition: color 120ms ease; } .thumb-base, .thumb-front { transition: transform 120ms ease-out; } }
    @media (prefers-reduced-transparency: reduce) { .capsule { background: #252527; backdrop-filter: none; -webkit-backdrop-filter: none; } }
    @media (prefers-contrast: more) { .capsule { border-color: rgba(255,255,255,.5); } .option { color: #c8c8cc; } }
  </style>
</head>
<body>
  <main class="demo">
    <div class="capsule" id="capsule" role="tablist" aria-label="效果模式">
      <span class="thumb-base" id="thumb-base" aria-hidden="true"></span>
      <span class="thumb-front" id="thumb-front" aria-hidden="true"></span>
      <button class="option" type="button" role="tab" aria-selected="true" tabindex="0" data-index="0">Rotate</button>
      <button class="option" type="button" role="tab" aria-selected="false" tabindex="-1" data-index="1">Pulse</button>
    </div>
    <p class="status" id="status" aria-live="polite">当前模式:Rotate</p>
  </main>
  <script>
    const capsule = document.querySelector('#capsule');
    const thumbs = [document.querySelector('#thumb-base'), document.querySelector('#thumb-front')];
    const options = [...document.querySelectorAll('.option')];
    const status = document.querySelector('#status');
    const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)');
    let value = 0, velocity = 0, target = 0, raf = 0, dragging = false, pointerId;

    function setMode(index, announce = true) {
      target = index;
      options.forEach((option, i) => {
        const selected = i === index;
        option.setAttribute('aria-selected', selected);
        option.tabIndex = selected ? 0 : -1;
      });
      if (announce) status.textContent = `当前模式:${options[index].textContent}`;
      if (reducedMotion.matches) { value = target; render(); return; }
      if (!raf) raf = requestAnimationFrame(step);
    }
    function render() { const transform = `translate3d(${value * 100}%, 0, 0)`; thumbs.forEach(thumb => { thumb.style.transform = transform; }); }
    function step() {
      const displacement = target - value;
      velocity += displacement * 0.18;
      velocity *= 0.72;
      value += velocity;
      if (Math.abs(displacement) < .001 && Math.abs(velocity) < .001) { value = target; velocity = 0; raf = 0; render(); return; }
      render(); raf = requestAnimationFrame(step);
    }
    function choose(index) { setMode(index); options[index].focus({ preventScroll: true }); }
    options.forEach(option => {
      option.addEventListener('pointerdown', event => { option.classList.add('is-pressed'); option.setPointerCapture(event.pointerId); });
      option.addEventListener('pointerup', event => { option.classList.remove('is-pressed'); if (event.pointerId === pointerId) dragging = false; });
      option.addEventListener('pointercancel', () => option.classList.remove('is-pressed'));
      option.addEventListener('click', () => choose(Number(option.dataset.index)));
      option.addEventListener('keydown', event => {
        if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { event.preventDefault(); choose(event.key === 'ArrowRight' ? 1 : 0); }
        if (event.key === 'Home') { event.preventDefault(); choose(0); }
        if (event.key === 'End') { event.preventDefault(); choose(1); }
      });
    });
    capsule.addEventListener('pointerdown', event => {
      if (event.target === options[0] || event.target === options[1]) return;
      dragging = true; pointerId = event.pointerId; capsule.setPointerCapture(pointerId); velocity = 0;
    });
    capsule.addEventListener('pointermove', event => {
      if (!dragging || event.pointerId !== pointerId) return;
      const rect = capsule.getBoundingClientRect();
      const next = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width * 2 - .5));
      value = next; render();
    });
    capsule.addEventListener('pointerup', event => { if (!dragging || event.pointerId !== pointerId) return; dragging = false; choose(value > .5 ? 1 : 0); });
    capsule.addEventListener('pointercancel', () => { dragging = false; choose(target); });
    render();
  </script>
</body>
</html>

胶囊仿液态玻璃按钮制作指南

本文记录 capsule-button.html 中胶囊分段按钮的制作过程。核心目标不是依靠复杂渐变模拟玻璃,而是通过两个同色胶囊叠加,仅保留顶部高光边框,建立接近 Apple 液态玻璃控件的厚度感。

一、最终结构

按钮由四层组成:

  1. 外层底槽:深色长胶囊,负责容纳两个选项。
  2. 底层选中胶囊:带完整灰白边框,负责提供顶部高光。
  3. 上层覆盖胶囊:比底层稍矮,覆盖不需要显示的边框。
  4. 文字按钮:位于两个视觉胶囊上方,负责点击、键盘和无障碍交互。
<div class="capsule" role="tablist" aria-label="效果模式">
  <span class="thumb-base" aria-hidden="true"></span>
  <span class="thumb-front" aria-hidden="true"></span>

  <button class="option" role="tab" aria-selected="true">Rotate</button>
  <button class="option" role="tab" aria-selected="false">Pulse</button>
</div>

底层和上层胶囊都只是视觉元素,因此需要使用 aria-hidden="true"。真正的交互和语义由两个 button 承担。

二、制作外层胶囊底槽

外层使用两列网格,使两个选项始终平均分配宽度:

.capsule {
  --inset: 6px;
  position: relative;
  isolation: isolate;
  display: grid;
  grid-template-columns: 1fr 1fr;
  width: min(100%, 760px);
  min-height: clamp(76px, 11vw, 130px);
  padding: var(--inset);
  border: 0;
  border-radius: 999px;
  background: #1b1b1b;
  touch-action: pan-y;
  user-select: none;
}

关键点:

  • border-radius: 999px 保证不同尺寸下始终呈现完整胶囊形状。
  • grid-template-columns: 1fr 1fr 保证每个选项占据一半宽度。
  • --inset 同时控制选中胶囊与外层底槽之间的间距。
  • touch-action: pan-y 允许横向操作按钮,同时保留页面纵向滚动。
  • isolation: isolate 建立独立层叠环境,避免内部 z-index 影响外部元素。

三、制作带边框的底层胶囊

底层胶囊拥有完整的灰白色边框:

.thumb-base,
.thumb-front {
  position: absolute;
  z-index: 0;
  left: var(--inset);
  width: calc(50% - var(--inset));
  border-radius: 999px;
  pointer-events: none;
  will-change: transform;
}

.thumb-base {
  top: var(--inset);
  bottom: var(--inset);
  background: #4a4a4a;
  border: 2px solid #6b6b6b;
}

底层胶囊宽度为外层的一半,并扣除一侧内边距。它的边框原本会完整显示,但下一步会用上层胶囊覆盖不需要的部分。

四、叠加上层覆盖胶囊

上层胶囊使用相同灰色基调,并设置为 95% 不透明度:

.thumb-front {
  top: calc(var(--inset) + 2px);
  bottom: var(--inset);
  background: rgba(74, 74, 74, 0.95);
}

这里是整个效果最关键的几何关系:

  • 上层与底层宽度相同。
  • 上层与底层底部完全对齐。
  • 上层顶部向下偏移 2px
  • 2px 正好等于底层边框厚度。
  • 上层覆盖底层的下边框、左下边框和右下边框。
  • 最终只露出底层顶部的灰白边框。

如果顶部偏移大于边框厚度,例如使用 7px,用户会看到明显的两个色块,效果会变成分层卡片,而不是一体化玻璃按钮。

五、为什么两层颜色必须一致

底层和上层需要使用相同的 RGB 基色:

/* 底层 */
background: #4a4a4a;

/* 上层 */
background: rgba(74, 74, 74, 0.95);

上层仅通过 0.95 的透明度产生极轻微的材质变化。如果两层使用不同灰色,例如底层 #424242、上层 #4a4a4a,顶部区域会形成可见色带,破坏整体性。

要点是:

  • 立体感来自边缘遮挡关系,而不是明显色差。
  • 透明度差异应当很轻,不能让用户直接辨认出两块独立图形。
  • 高光只用于表达表面受光,不应形成完整描边。

六、文字与层级

文字按钮必须位于两个视觉胶囊之上:

.option {
  position: relative;
  z-index: 1;
  min-width: 0;
  border: 0;
  border-radius: 999px;
  background: transparent;
}

建议层级:

z-index: 1  文字按钮
z-index: 0  上层覆盖胶囊
z-index: 0  底层边框胶囊
             外层深色底槽

上层覆盖胶囊写在底层胶囊之后,因此在相同 z-index 下会自然覆盖底层。

七、让两层作为一个整体移动

底层和上层必须使用完全相同的位移值。不能分别计算或分别启动动画,否则边框会与主体错位。

const thumbs = [
  document.querySelector('#thumb-base'),
  document.querySelector('#thumb-front')
];

function render() {
  const transform = `translate3d(${value * 100}%, 0, 0)`;

  thumbs.forEach((thumb) => {
    thumb.style.transform = transform;
  });
}

使用 transform: translate3d() 的原因:

  • 不触发布局重排。
  • 通常由浏览器合成线程处理,动画更平滑。
  • 两层共享同一个 transform,可以长期保持像素级对齐。

八、弹簧切换

选中项切换时,通过当前值、目标值和速度计算弹簧移动:

function step() {
  const displacement = target - value;

  velocity += displacement * 0.18;
  velocity *= 0.72;
  value += velocity;

  if (Math.abs(displacement) < 0.001 && Math.abs(velocity) < 0.001) {
    value = target;
    velocity = 0;
    raf = 0;
    render();
    return;
  }

  render();
  raf = requestAnimationFrame(step);
}

这段动画没有固定关键帧,而是从当前屏幕值继续运动。因此用户在动画尚未结束时再次切换,胶囊也不会跳回旧位置。

参数含义:

  • 0.18 控制弹簧拉向目标的力度。
  • 0.72 控制速度衰减。
  • value 在左侧时为 0,右侧时为 1
  • value * 100% 正好移动一个选项宽度。

九、即时按下反馈

Apple 风格交互要求在按下时立即反馈,而不是等到点击释放:

.option:active,
.option.is-pressed {
  transform: scale(0.975);
}
option.addEventListener('pointerdown', (event) => {
  option.classList.add('is-pressed');
  option.setPointerCapture(event.pointerId);
});

使用 Pointer Events 可以同时覆盖鼠标、触控笔和触摸屏。

十、键盘与无障碍

该控件使用 tablisttab 语义:

  • aria-selected="true" 表示当前选项。
  • 未选中项设置 tabindex="-1",避免产生两个连续 Tab 停靠点。
  • 支持左右方向键、Home 和 End。
  • 状态提示使用 aria-live="polite",但在视觉上隐藏。
.status {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

不要使用 display: none 隐藏状态提示,否则读屏软件也无法读取更新内容。

十一、减少动态效果

对于启用了“减少动态效果”的用户,不运行弹簧动画,直接更新位置,并保留短促的视觉过渡:

@media (prefers-reduced-motion: reduce) {
  .option {
    transition: color 120ms ease;
  }

  .thumb-base,
  .thumb-front {
    transition: transform 120ms ease-out;
  }
}

减少动态效果不等于取消所有反馈。选中颜色、焦点轮廓和状态变化仍应保留。

十二、响应式尺寸

桌面端使用 6px 内边距,移动端收紧到 4px

@media (max-width: 520px) {
  .capsule {
    --inset: 4px;
    min-height: 64px;
  }
}

顶部露出的边框仍保持 2px,因为它是材质细节,不应该随组件尺寸大幅变化。

十三、关键参数表

参数当前值作用
外层背景#1b1b1b深色底槽
选中胶囊基色#4a4a4a两层统一的主体颜色
上层不透明度95%产生轻微材质变化
底层边框2px solid #6b6b6b顶部灰白高光来源
上层顶部偏移2px只露出顶部边框
桌面内边距6px胶囊与底槽间距
移动端内边距4px小屏紧凑间距
按下缩放0.975即时触摸反馈
弹簧拉力0.18移动响应速度
速度衰减0.72控制弹簧收敛

十四、常见错误

1. 上层胶囊过矮

如果顶部偏移明显超过边框厚度,会露出底层填充色,形成一条宽色带。正确做法是让偏移量等于边框厚度。

2. 两层颜色不同

颜色不同会让用户直接看到两个胶囊,而不是一个有厚度的整体。两层必须使用相同 RGB 基色。

3. 上层没有覆盖到底部

上层与底层必须底部对齐。如果上层同时向内收缩顶部和底部,底层下边框会再次露出,变成普通描边按钮。

4. 两层分别动画

分别更新动画容易出现一帧错位。应当计算一次 transform,再同时赋值给两个胶囊。

5. 使用过重阴影和渐变

这个效果主要依靠结构遮挡和顶部高光。过重的内阴影、外阴影或多段渐变会让按钮变得浑浊,失去 Apple 风格的克制感。

6. 忽略焦点和键盘操作

胶囊看起来像一个整体,但实际包含两个选择项。必须保留清晰的焦点状态、选择语义和方向键操作。

十五、制作要点总结

  1. 先做正确的几何叠层,再考虑材质效果。
  2. 底层提供完整边框,上层负责有选择地遮挡边框。
  3. 上层只比底层矮一个边框厚度。
  4. 两层使用相同颜色,上层设置 95% 不透明度。
  5. 上层与底层底部对齐,只露出底层顶部边框。
  6. 两层共享同一个 transform,永远作为一个整体移动。
  7. 交互反馈从 pointerdown 开始,动画保持可中断。
  8. 保留键盘、读屏和减少动态效果支持。
  9. 避免用明显色差、重阴影或复杂渐变替代正确的层次关系。

当前完整实现位于同目录下的 capsule-button.html

THE END
喜欢就支持一下吧
点赞11 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容