<!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 液态玻璃控件的厚度感。
一、最终结构
按钮由四层组成:
- 外层底槽:深色长胶囊,负责容纳两个选项。
- 底层选中胶囊:带完整灰白边框,负责提供顶部高光。
- 上层覆盖胶囊:比底层稍矮,覆盖不需要显示的边框。
- 文字按钮:位于两个视觉胶囊上方,负责点击、键盘和无障碍交互。
<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 可以同时覆盖鼠标、触控笔和触摸屏。
十、键盘与无障碍
该控件使用 tablist 和 tab 语义:
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. 忽略焦点和键盘操作
胶囊看起来像一个整体,但实际包含两个选择项。必须保留清晰的焦点状态、选择语义和方向键操作。
十五、制作要点总结
- 先做正确的几何叠层,再考虑材质效果。
- 底层提供完整边框,上层负责有选择地遮挡边框。
- 上层只比底层矮一个边框厚度。
- 两层使用相同颜色,上层设置
95%不透明度。 - 上层与底层底部对齐,只露出底层顶部边框。
- 两层共享同一个 transform,永远作为一个整体移动。
- 交互反馈从
pointerdown开始,动画保持可中断。 - 保留键盘、读屏和减少动态效果支持。
- 避免用明显色差、重阴影或复杂渐变替代正确的层次关系。
当前完整实现位于同目录下的 capsule-button.html。


















暂无评论内容