UI 动效 013:Touch Ripple,为什么按钮一按会泛起波纹
July 7, 2026 · 8:07 AM

UI 动效 013:Touch Ripple,为什么按钮一按会泛起波纹

这期拆解 Touch Ripple / 触摸涟漪:它适合按钮、图标按钮和列表项的按下反馈,核心是从真实触点生成圆形状态层,再用半径、遮罩和透明度把波纹限制在控件边界内。文中用自制 GIF、Material Web、Android RippleDrawable、Compose ripple API 和 Web 代码示例说明怎么做得顺,也怎么避免把视觉反馈误当操作结果。

触摸涟漪最好看的地方,不是那一圈波纹本身,而是它把「我刚刚按到了这里」说得很快。手指落下的坐标变成圆心,半透明状态层从圆心扩散到按钮边界,然后在释放后淡出。一个不到半秒的反馈,能让按钮、列表项、图标按钮都少一点「按了没反应」的悬空感。
Touch Ripple 触摸涟漪自制 GIF 演示
Touch Ripple 触摸涟漪自制 GIF 演示
自制机制示意:波纹从按下点扩散,被按钮圆角裁切,最后淡出;非真实产品截图。

它叫什么

这个动效常见叫法是 Touch RippleInk RippleRipple Effect,中文可以叫「触摸涟漪」或「点击涟漪」。Material Web 把 ripple 归到 state layer,也就是用一层半透明覆盖物表达组件当前状态;这层覆盖物可以覆盖整个元素,也可以呈现为圆形。1
在 Android 体系里,它对应 RippleDrawable:这是一个会响应状态变化显示 ripple 的 Drawable,触点锚点可以通过 setHotspot(float, float) 指定。2 这句话翻成实现语言就是:波纹不是按钮内容的一部分,而是一层临时绘制的反馈层。

适合放在哪里

Touch Ripple 适合「点一下就触发」的控件:按钮、图标按钮、菜单项、列表行、卡片入口。它传达的是按下反馈,不是页面跳转,也不是加载进度。
不适合的场景也很明确。拖拽、滑动、长按菜单这类手势,本身已经有位移、抬起、露出动作等反馈,再叠一层强 ripple 可能显得吵。危险操作也要谨慎:删除、支付、提交订单这些动作,涟漪只能说明「已按下」,不能替代二次确认、撤销或 loading 状态。

为什么看起来像水波

它的视觉逻辑可以拆成四步。
  1. 记录按下点。用户按下时,读取 pointer 坐标,再减去按钮自身的位置,得到按钮内部的圆心。Web 里常用 getBoundingClientRect() 拿到元素相对视口的位置和尺寸。3
  2. 算最大半径。圆要扩散到能盖住按钮最远的角,所以半径通常取「按下点到四个角的最大距离」。这样无论点在左边、右边还是中间,波纹都能填满按钮。
  3. 用遮罩裁切。按钮是圆角矩形,波纹是圆。把 ripple 放进 overflow: hidden 的按钮层里,圆就会被按钮边界裁掉,看起来像在按钮表面流动。
  4. 扩散并淡出。圆从 scale(0)scale(1),同时把 opacity 从可见降到 0。CSS 的 transform 可以对元素做缩放、位移和旋转,适合处理这种不想触发布局重排的动画。4 如果不用额外 DOM,也可以用 radial-gradient() 画出从圆心向外扩散的圆形渐变;MDN 对它的定义就是从一个 origin 向外辐射的颜色过渡。5

Web 里怎么写

下面是一个简化版本,重点看坐标、半径和清理。真实项目里还要处理键盘触发、禁用态、连续点击和 reduced motion。
<button class="ripple-button">确认支付</button>
.ripple-button {
  position: relative;
  overflow: hidden;
  border: 0;
  border-radius: 16px;
  padding: 14px 24px;
  color: white;
  background: #2f6fed;
}

.ripple-button .ripple {
  position: absolute;
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.38);
  transform: scale(0);
  pointer-events: none;
  animation: ripple-out 520ms ease-out forwards;
}

@keyframes ripple-out {
  to {
    transform: scale(1);
    opacity: 0;
  }
}
document.querySelectorAll('.ripple-button').forEach((button) => {
  button.addEventListener('pointerdown', (event) => {
    const rect = button.getBoundingClientRect();
    const x = event.clientX - rect.left;
    const y = event.clientY - rect.top;
    const radius = Math.hypot(
      Math.max(x, rect.width - x),
      Math.max(y, rect.height - y)
    );

const ripple = document.createElement('span');
    ripple.className = 'ripple';
    ripple.style.width = ripple.style.height = `${radius * 2}px`;
    ripple.style.left = `${x - radius}px`;
    ripple.style.top = `${y - radius}px`;

button.append(ripple);
    ripple.addEventListener('animationend', () => ripple.remove());
  });
});
Material Web 的 md-ripple 也提醒了一件小事:ripple 要放在 position: relative 的容器里,否则这层反馈无法稳定贴住控件。6

Android / Compose 的实现思路

如果做 Android 原生界面,RippleDrawable 已经把颜色、遮罩、内容层和热点位置这些事封装好了。它可以有多个 child layer,也可以指定一个特殊 mask layer;有 mask 时,ripple 会先按 mask 裁切再绘制。2
Jetpack Compose 里,新的 Material ripple API 推荐直接用 ripple(),再传给 Modifier.clickable 或其他组件;官方迁移文档还说明,新的 API 不再需要 rememberRipple(),并且可以复用以减少分配。7
Box(
    modifier = Modifier.clickable(
        interactionSource = remember { MutableInteractionSource() },
        indication = ripple(),
        onClick = { /* handle click */ }
    )
) {
    Text("确认支付")
}

做顺的关键

关键点该怎么做做错会怎样
圆心从真实按下点开始扩散每次都从中心扩散,手感会假
半径覆盖到最远角点在边缘时波纹盖不满按钮
裁切跟随按钮圆角和边界波纹溢出到按钮外面
时长约 400-600ms,释放后淡出太短像闪烁,太长拖慢点击反馈
语义ripple 只做视觉反馈用户可能误以为操作已经完成
Material Web 文档还把 accessibility 说得很克制:ripples 是视觉组件,本身没有额外的辅助技术要求。8 但这不等于可以只靠 ripple 表达状态。按钮仍然要有可聚焦状态、键盘触发、文本标签和禁用态;对容易晕动的用户,也应该尊重 prefers-reduced-motion,MDN 在 transform 页面里也提醒缩放动画可能触发不适,应提供减少动画的方案。9

一句话记住

Touch Ripple 的核心不是「画一个圆」,而是把点击位置、元素边界和状态反馈绑在一起:圆心告诉用户按到了哪里,裁切告诉用户反馈属于哪个控件,淡出告诉用户这只是按下瞬间,不是结果状态。

Related content

  • Sign in to comment.
More from this channel