# 滚动绑定 Scroll-linked Animation > 画面跟着滚动条走,滚多少动多少,往回滚就倒放。 分类:动效 · 角色:主角(占一整块、抢全部注意力,一页最多一个) [全部组件和流程](https://www.dachuiai.com/llms.txt) · [动效选型规则](https://www.dachuiai.com/guides/motion.md) ## 给 AI:用户把这个组件发给了你 1. 先看用户的项目:框架、页面类型、现在的风格、手机还是电脑为主。 2. 对照下面的「适合 / 不适合」判断合不合适。不合适就直接说,并从[动效选型规则](https://www.dachuiai.com/guides/motion.md)或[全部组件](https://www.dachuiai.com/llms.txt)里另挑一个。 3. 合适就按「装进项目」装好,示例里的文字和图片换成用户自己的。 4. 告诉用户用了什么、放在哪个文件、以后想改快慢、颜色、大小改哪个参数。 ## 什么时候用 - 适合:讲清一个产品的结构或过程:零件一层层拆开、步骤一步步展开,让人边滚边看懂。 - 不适合:没有可以拆开的画面;只想让内容出现一下(用滚动出现);后台和表单。 - 适合的网站:官网、SaaS、电商、活动页 - 放在:内容区 - 手机上:好用:滚动在手机上一样触发;窄屏要把零件的位移改小。 - 性能代价:中:每帧按滚动进度改 transform,参与动画的元素控制在几十个以内。 - 常搭配:[滚动叙事 Scroll Storytelling](https://www.dachuiai.com/c/scroll-story.md)、[滚动出现 Scroll Reveal](https://www.dachuiai.com/c/scroll-reveal.md)、[数字滚动 Count Up](https://www.dachuiai.com/c/count-up.md) ## 装进项目 - 写法:React 18 以上 + TypeScript + Tailwind CSS v4,一个文件就能用,不引用别的组件。 - 依赖:只用 react,不用另外安装 - 文件:原样存成 `scroll-linked.tsx`(比如 `src/components/scroll-linked.tsx`)。能跑命令就下载[原文](https://www.dachuiai.com/code/scroll-linked.tsx),比手抄准:`curl -fsSL -o src/components/scroll-linked.tsx https://www.dachuiai.com/code/scroll-linked.tsx`(Windows 写 `curl.exe`)。下载完看一眼第一行是 `import`,不是网页。 - 导出:`useScrollProgress`、`seg`,是做这个效果用的工具(hook、函数或常量);页面结构在最后的 `export default` 示例里,照着示例搭。 - 图片:示例用了本站的 [/art/planet.jpg](https://www.dachuiai.com/art/planet.jpg)。换成用户自己的图;只想先看效果,可以从本站同样的路径下载,放进项目的 public 目录。 - 注意:useScrollProgress 算的是整页(或传入的滚动盒子)从顶到底的进度。放在长页面中间时,要改成按这一段自己的位置算:进度 = (视口顶 − 段落顶) ÷ (段落高 − 视口高)。seg(p, a, b) 把进度里的一段换成 0–1。画面钉住靠 position: sticky,外面任何一层写了 overflow: hidden 或 overflow-x: hidden 都会失效,改用 overflow-x: clip。 - 保留代码里 `prefers-reduced-motion` 的处理:用户开了「减弱动态效果」时内容照样完整显示。 - Next.js(有 app 文件夹):文件第一行加 `'use client';`。 - 用 JavaScript 不用 TypeScript:Vite 和 Next.js 都能直接用 .tsx 文件;也可以删掉类型标注,改名 .jsx。 - 没有 Tailwind:先装 Tailwind CSS v4(Vite 项目 `npm i tailwindcss @tailwindcss/vite`,vite 配置的 plugins 里加 `tailwindcss()`,入口引入的那个 CSS 文件(一般是 `src/index.css`)第一行写 `@import "tailwindcss";`),或者把类名改写成项目用的样式。 - 已经装了 Tailwind v3:别为了一个组件升级。类名大多通用,少数 v4 写法要换,比如焦点框 `outline-2` 在 v3 里要写成 `outline outline-2`。 - 不是 React(Vue、Svelte、小程序、纯 HTML):照着代码的做法改写,时长、曲线、参数保持一样。Wix、有赞这类建站平台装不进代码,告诉用户去平台自带的动效设置里找。 ## 参数 | 参数 | 默认值 | 作用 | | --- | --- | --- | | `box` | | 滚动盒子的 ref;整页滚动时不传 | | `smooth` | `0.14` | 跟随的平滑程度,越小越顺滑、越拖 | ## 你可以说 > 产品展示这一段做成 Scroll-linked Animation 滚动绑定:手机零件跟着滚动条一层层拉开,滚多少动多少,往回滚就倒放。 ## 完整代码 ```tsx import {useEffect, useRef, useState, type CSSProperties, type RefObject} from 'react'; /* --------------------------------------------------------------- * ScrollLinked 滚动绑定 Scroll-linked Animation * 动画跟着滚动条走:滚多少,动多少,往回滚就倒放。 * useScrollProgress:返回 0–1 的滚动进度,所有画面都从这一个数算出来。 * 不是按时间播放,所以不存在"播完了",滚动条就是进度条。 * 整页用时 box 不传;放在可滚动的盒子里时,传这个盒子。 * ------------------------------------------------------------- */ export function useScrollProgress(box?: RefObject, smooth = 0.14) { const [p, setP] = useState(0); useEffect(() => { const el = box?.current; let raf = 0, cur = 0, goal = 0; const read = () => { const top = el ? el.scrollTop : scrollY; const max = el ? el.scrollHeight - el.clientHeight : document.documentElement.scrollHeight - innerHeight; goal = max > 0 ? top / max : 0; if (!raf) raf = requestAnimationFrame(tick); }; // 跟随时稍微缓一下,滚轮一格一格时画面也是顺的 const tick = () => { cur += (goal - cur) * (matchMedia('(prefers-reduced-motion: reduce)').matches ? 1 : smooth); if (Math.abs(goal - cur) < 0.0004) cur = goal; setP(cur); raf = cur === goal ? 0 : requestAnimationFrame(tick); }; const target = el ?? window; target.addEventListener('scroll', read, {passive: true}); read(); return () => { target.removeEventListener('scroll', read); cancelAnimationFrame(raf); }; }, [box, smooth]); return p; } /** 把进度里的一段 [a, b] 映射成 0–1,再做一次先快后慢 */ export const seg = (p: number, a: number, b: number) => { const t = Math.min(1, Math.max(0, (p - a) / (b - a))); return t * t * (3 - 2 * t); }; const mix = (a: number, b: number, t: number) => a + (b - a) * t; /* ---------------- 示例:手机零件爆炸图 ---------------- */ const PARTS = [ {name: '超瓷玻璃', note: '抗摔能力提升 4 倍'}, {name: '全天候屏幕', note: '峰值亮度 3000 尼特'}, {name: '三层主板', note: '体积缩小 30%'}, {name: '硅碳电池', note: '5500 mAh,满电 38 小时'}, {name: '钛金属中框', note: '整机只有 165 克'}, ]; const Layer = ({z, children, style}: {z: number; children?: React.ReactNode; style?: CSSProperties}) => (
{children}
); export default function ScrollLinkedDemo() { const box = useRef(null); const p = useScrollProgress(box); const tilt = seg(p, 0, 0.3); // 先转到斜上方的视角 const open = seg(p, 0.22, 0.78); // 再把零件一层层拉开 const spin = seg(p, 0.7, 1); // 最后再转半圈 const gap = open * 104; const active = Math.min(PARTS.length - 1, Math.floor(seg(p, 0.26, 0.92) * PARTS.length)); const device: CSSProperties = { transform: `translateY(${mix(0, -14, tilt)}px) rotateX(${mix(0, 62, tilt)}deg) rotateZ(${mix(0, -38, tilt) + spin * 74}deg) scale(${mix(1, 0.8, open)})`, transformStyle: 'preserve-3d', }; return (

薄,
但什么都有

    {PARTS.map((s, i) => (
  1. 0{i + 1} {s.name} {s.note}
  2. ))}
{/* 从下往上一层层叠:中框、电池、主板、屏幕、玻璃 */}
5500 mAh
); } ```