---
name: better-interaction
description: >
为网页前端增加“有灵魂”的交互与动效:滚动驱动、指针响应、微交互、
页面过渡、组件交互状态。配合 better-theme 使用,让静态页面真正活起来。
Use when the user asks to add animations, interactions, motion, scroll effects,
micro-interactions, hover states, page transitions, "让页面动起来",
"加点动效", "交互优化", "make it feel alive", or after a better-theme
page has been built and needs motion / interaction layer.
---
> 姊妹 Skill:`better-theme`(配色 / 布局 / 文案)
> 本 Skill 只负责“动起来 + 交互反馈”,不负责选色和布局。
## About Better Interaction
`better-theme` 决定了页面**长什么样**,本 Skill 决定页面**怎么回应人**。
目标不是“加一堆动画”,而是:
- 用户滚动时,内容**按节奏出现**,而不是一次性砸脸上
- 鼠标移动时,界面**有轻微反馈**,而不是一块死板的图
- 点击按钮时,**0.1 秒内**给出物理感回应
- 切页 / 打开面板时,**空间连续**,不闪不跳
- 一切动效**服从可访问性**:`prefers-reduced-motion` 必须被尊重
## When To Use
- 已完成静态页面,用户觉得“太干”“太死”“没有高级感”
- 需要落地页 / 品牌页的“惊艳感”
- 需要 Dashboard / 工具页的“反馈感”与“状态感”
- 需要移动端的手势与触感反馈
**不要用**:
- 纯文档站(Markdown 渲染页、API reference)
- 用户明确说“不要动效 / 保持朴素”
- 数据表格密集型后台(动效会干扰读数)
## 执行流程
1. **先扫一眼现有页面**:它是 brand 型、product 型、还是 dashboard 型?
- brand:动效可以更戏剧化(视差、滚动叙事、光标跟随)
- product:动效克制,主要用进入动画 + 悬停反馈
- dashboard:只做数字变化动画 + 状态过渡,不加装饰性动效
2. **确定动效预算**:单页不要超过 3 类动效同时存在,否则视觉噪音爆炸。
3. **选技术栈**(按项目实际情况,优先级从高到低):
- 纯 CSS(`transition` / `@keyframes` / `animation-timeline`)
- Intersection Observer + CSS
- 轻量库:`motion`、`@formkit/auto-animate`、`lenis`
- 重型:GSAP + ScrollTrigger(只在叙事型落地页才引入)
4. **实现顺序**:进入 → 悬停 → 点击 → 滚动 → 页面过渡。**从简到繁,不要反过来。**
5. **测试三件事**:低端机是否掉帧、键盘导航是否可用、`prefers-reduced-motion` 下是否退化为静态。
---
## 一、进入动效(Entry)
**原则**:内容进入视口时**分批次**出现,节奏感来自**时间差**,不是时长。
### 推荐做法
```css
@keyframes rise-in {
from {
opacity: 0;
transform: translateY(16px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.reveal {
opacity: 0;
animation: rise-in 0.6s cubic-bezier(0.2, 0.7, 0.2, 1) forwards;
animation-delay: calc(var(--i, 0) * 80ms);
}
```
<div class="reveal" style="--i:0">...</div>
<div class="reveal" style="--i:1">...</div>
<div class="reveal" style="--i:2">...</div>
const io = new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (e.isIntersecting) {
e.target.classList.add("is-in");
io.unobserve(e.target);
}
}
},
{ rootMargin: "0px 0px -10% 0px", threshold: 0.1 },
);
document.querySelectorAll(".reveal").forEach((el) => io.observe(el));
.reveal的隐藏样式写在样式表里,不要等 JS 注入——否则会先看到元素再闪一下。
ease-in 做进入,用 ease-out 或 cubic-bezier(.2,.7,.2,1)原则:鼠标是最精准的输入设备,但不要依赖它传递信息(触屏没有)。
.btn {
transition:
transform 0.15s ease,
background 0.2s ease,
box-shadow 0.2s ease;
}
.btn:hover {
transform: translateY(-1px);
}
.btn:active {
transform: translateY(0) scale(0.98);
}
要点:
:active 一定要有,给出“按下去”的物理感.card {
transition:
transform 0.3s cubic-bezier(0.2, 0.7, 0.2, 1),
box-shadow 0.3s ease;
}
.card:hover {
transform: translateY(-4px);
box-shadow: 0 12px 32px -12px rgb(0 0 0 / 0.18);
}
if (matchMedia("(hover: hover) and (pointer: fine)").matches) {
const glow = document.querySelector(".cursor-glow");
let x = innerWidth / 2,
y = innerHeight / 2,
tx = x,
ty = y;
addEventListener("pointermove", (e) => {
tx = e.clientX;
ty = e.clientY;
});
(function loop() {
x += (tx - x) * 0.12;
y += (ty - y) * 0.12;
glow.style.transform = `translate3d(${x}px, ${y}px, 0)`;
requestAnimationFrame(loop);
})();
}
要点:
requestAnimationFrame + 阻尼,不要直接跟 mousemovemix-blend-mode: soft-light 或 plus-lighter原则:滚动动效是“叙事工具”,不是“装饰工具”。用错地方会让页面像 PPT。
position: sticky + 滚动切换文案.hero-img {
animation: scale-in linear both;
animation-timeline: view();
animation-range: entry 10% cover 40%;
}
@keyframes scale-in {
from {
transform: scale(0.92);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
animation-timeline跑在合成器线程上,零 JS。JS 滚动监听是移动端掉帧的主要元凶之一。Safari 需要 polyfill,其他浏览器基本可用。
preventDefault on wheel)——除非是横向叙事页,且必须提供退出方式scroll-behavior: smooth 用于常规导航——会和键盘 / 屏读冲突will-change: transform,但用完要撤,不要全程挂着prefers-reduced-motion 关掉每个可交互元素必须显式定义:
| 状态 | 视觉要求 |
|---|---|
| default | 基础样式 |
| hover | 变化幅度小、有目的 |
| focus-visible | 必须可见,2px 外描边或等效对比 |
| active | 物理感(下沉 / 收缩) |
| disabled | 降透明度 + cursor: not-allowed,不带 hover |
| loading | 内容占位或 spinner,禁用点击 |
| error | 用色 + 图标,不只用颜色传递 |
.accordion-panel {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.3s cubic-bezier(0.2, 0.7, 0.2, 1);
}
.accordion-panel[data-open="true"] {
grid-template-rows: 1fr;
}
.accordion-panel > div {
overflow: hidden;
}
要点:
grid-template-rows: 0fr → 1fr 代替 max-height 黑魔法必须做到:
Esc 关闭inert(不是只有 aria-hidden)document.body.style.overflow = "hidden";
document.body.style.paddingRight =
innerWidth - document.documentElement.clientWidth + "px";
function animateNumber(el, to, duration = 900) {
const from = Number(el.dataset.from || 0);
const start = performance.now();
function tick(now) {
const t = Math.min(1, (now - start) / duration);
const eased = 1 - Math.pow(1 - t, 3);
el.textContent = Math.round(from + (to - from) * eased).toLocaleString();
if (t < 1) requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
}
.view {
opacity: 0;
transform: translateY(8px);
transition:
opacity 0.25s ease,
transform 0.25s ease;
}
.view.is-active {
opacity: 1;
transform: none;
}
优先用 View Transitions API,一行 CSS 就能开 MPA 过渡:
@view-transition {
navigation: auto;
}
if (document.startViewTransition) {
document.startViewTransition(() => {
/* 更新 DOM 或导航 */
});
}
Chrome / Edge / Safari 已支持,Firefox 部分不支持。未支持就静默降级为普通跳转,别 polyfill。
60fps 是底线,不是目标。 动效掉帧 = 负分。
| 安全(合成器) | 危险(触发 layout / paint) |
|---|---|
transform |
width / height |
opacity |
margin / padding |
filter(谨慎) |
top / left / right / bottom |
clip-path(谨慎) |
border-radius / font-size |
渲染管线:Layout → Paint → Composite。
transform/opacity直接跳过前两步,剩下的交给 GPU。filter和clip-path虽然也算合成器属性,但blur这种在低端机上仍然吃力,谨慎使用。
will-changewill-change 是“最后手段”@keyframes + animation-delay 让 GPU 批处理new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration > 50) console.warn("Long frame:", entry.duration);
}
}).observe({ type: "long-animation-frame", buffered: true });
prefers-reduced-motion@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
不是所有动效都得删——保留功能,移除运动:
- 视差 → 静态布局
- 滑动进入 → 交叉淡入
- 大幅移动 → 仅透明度变化
:focus-visible 不能被 outline: none 干掉focus-visible 也要有role + 方向键导航| 元素 | 时长 |
|---|---|
| Tooltip / 微反馈 | 80–120ms |
| 按钮 / 开关 | 120–180ms |
| 卡片进入 / 退出 | 200–350ms |
| 模态框 / 对话框 | 300–400ms |
| 页面过渡 | 400–600ms |
| 戏剧性揭示 | 600–1200ms |
退出动画时长为进入的 65–75%,太快会显得被“掐断”。
| 场景 | 缓动 |
|---|---|
| 进入 | ease-out |
| 退出 | ease-in |
| 屏上移动 | ease-in-out |
| 循环 | sine |
| 旋转 / 进度 | linear |
| 名称 | 值 | 用途 |
|---|---|---|
| MD3 Standard | (0.2, 0, 0, 1) |
默认屏上动画 |
| MD3 Emphasized | (0.05, 0.7, 0.1, 1) |
进入动画 |
| Gentle float | (0.4, 0, 0.2, 1) |
氛围动画 |
| Bounce settle | (0.175, 0.885, 0.32, 1.275) |
俏皮风格 |
| Elastic snap | (0.68, -0.55, 0.265, 1.55) |
戏剧性 |
| 风格 | 快 | 标准 | 慢 |
|---|---|---|---|
| 俏皮 | 150ms | 250ms | 400ms |
| 高端 | 350ms | 500ms | 800ms |
| 企业 | 200ms | 300ms | 450ms |
| 活力 | 100ms | 180ms | 300ms |
#0a0a0a 底 / #111 抬升)oklch() 定义,感知均匀、支持 P3,现代浏览器都有禁止:
onmouseover 里改 style.width——layout thrashwill-change 写死在 CSS——应动态开关,不是常驻easeOut| 页面类型 | 进入 | 悬停 | 滚动 | 页面过渡 | 光标 |
|---|---|---|---|---|---|
| 品牌落地页 | 明显 | 有 | 视差 / 粘性叙述 | 有 | 可 |
| 产品官网 | 轻 | 有 | 进度条 | 轻 | 否 |
| SaaS 后台 | 极轻 | 有 | 无 | 无 | 否 |
| 文档站 | 无 | 有 | 章节高亮 | 无 | 否 |
| 电商 | 轻 | 有(图片放大) | 无 | 有(加入购物车) | 否 |
| 艺术 / 展览 | 明显 | 有 | 视差 | 有 | 可 |
:root {
--ease-out: cubic-bezier(0.2, 0.7, 0.2, 1);
--ease-in-out: cubic-bezier(0.6, 0, 0.2, 1);
--ease-spring: cubic-bezier(0.175, 0.885, 0.32, 1.275);
--dur-fast: 150ms;
--dur-base: 300ms;
--dur-slow: 600ms;
}
const prefersReduced = matchMedia("(prefers-reduced-motion: reduce)");
if (!prefersReduced.matches) {
// 只在允许动效时初始化
}
export function follow(target, { damping = 0.12 } = {}) {
let x = 0,
y = 0,
tx = 0,
ty = 0,
raf;
const onMove = (e) => {
tx = e.clientX;
ty = e.clientY;
};
const loop = () => {
x += (tx - x) * damping;
y += (ty - y) * damping;
target.style.transform = `translate3d(${x}px, ${y}px, 0)`;
raf = requestAnimationFrame(loop);
};
return {
start() {
addEventListener("pointermove", onMove);
loop();
},
stop() {
removeEventListener("pointermove", onMove);
cancelAnimationFrame(raf);
},
};
}
if (matchMedia("(hover: none)").matches) {
document.documentElement.classList.add("no-motion");
}
.no-motion .cursor-glow,
.no-motion .parallax {
display: none;
}
const mm = gsap.matchMedia();
mm.add("(prefers-reduced-motion: no-preference)", () => {
// 所有 ScrollTrigger / timeline 代码放这
// 偏好变更时自动回滚
});
| 特性 | Chrome/Edge | Firefox | Safari | 降级 |
|---|---|---|---|---|
prefers-reduced-motion |
✅ | ✅ | ✅ | — |
animation-timeline |
✅ | ✅ 144+ | ⚠️ Polyfill | IntersectionObserver |
| View Transitions(SPA) | ✅ | ✅ 133+ | ✅ | 普通跳转 |
| View Transitions(MPA) | ✅ 126+ | ❌ | ✅ 18.2+ | 普通导航 |
interpolate-size |
✅ 129+ | ❌ | ❌ | max-height 方案 |
@starting-style |
✅ | ✅ | ✅ | JS 类切换 |
oklch() 颜色 |
✅ | ✅ | ✅ | hsl() |
prefers-reduced-motion 下退化为静态(保留功能,移除运动)?transform / opacity?:focus-visible 样式可见且对比度足够?Esc、body 滚动条?will-change 只在动画前后动态开关?animation-timeline 而非 JS 监听?记住一句话:动效是服务员,不是主角。 用户来是为了内容,动效只是让内容递得更舒服。
---
改动说明:把上一版的长引用、学术化段落、密集来源标注全部砍掉,回到 `better-theme` 那种**短句 + 要点 + 表格 + 代码**的密度。新内容(缓动表、Dark Mode、兼容性、`will-change` 警告)都塞进了原有章节结构里,没有单独开一堆“参考”章节。语气也拉回了原来那个有点嘴碎、偶尔吐槽、不装的状态。