# 滚动叙事 Scroll Storytelling > 一侧画面钉住不动,另一侧文字一段段滚过,画面跟着切换。 分类:动效 · 角色:主角(占一整块、抢全部注意力,一页最多一个) [全部组件和流程](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. 告诉用户用了什么、放在哪个文件、以后想改快慢、颜色、大小改哪个参数。 ## 什么时候用 - 适合:讲 3 到 5 个功能或步骤,每一步配一张图或一个手机界面。 - 不适合:只有一两个点;文章正文;后台。 - 适合的网站:SaaS、官网、电商、活动页、内容站 - 放在:内容区 - 手机上:要改:窄屏放不下左右两栏,手机上改成每段文字配自己的图,上图下文依次排。 - 性能代价:低:只判断哪一段文字在屏幕中间。 - 常搭配:[滚动出现 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,一个文件就能用,不引用别的组件。 - 依赖:`npm i lucide-react` - 文件:原样存成 `scroll-story.tsx`(比如 `src/components/scroll-story.tsx`)。能跑命令就下载[原文](https://www.dachuiai.com/code/scroll-story.tsx),比手抄准:`curl -fsSL -o src/components/scroll-story.tsx https://www.dachuiai.com/code/scroll-story.tsx`(Windows 写 `curl.exe`)。下载完看一眼第一行是 `import`,不是网页。 - 导出:`useActiveStep`,是做这个效果用的工具(hook、函数或常量);页面结构在最后的 `export default` 示例里,照着示例搭。 - 注意:useActiveStep(段数) 返回当前段的序号,给每段文字加上 bind(i)。画面钉住靠 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、有赞这类建站平台装不进代码,告诉用户去平台自带的动效设置里找。 ## 参数 | 参数 | 默认值 | 作用 | | --- | --- | --- | | `count` | | 数量 | | `root` | | 滚动盒子的 ref;整页滚动时不传 | ## 你可以说 > 这一段做成 Scroll Storytelling 滚动叙事:滚动时左边文字一段段切换,右边手机固定不动,里面的画面跟着变。 ## 完整代码 ```tsx import {useEffect, useRef, useState, type RefObject} from 'react'; import {BatteryCharging, Camera, Feather} from 'lucide-react'; /* --------------------------------------------------------------- * ScrollStory 滚动叙事 * 往下滚的时候,一侧的画面固定不动,里面的内容跟着滚到哪一段一步步变化。 * useActiveStep:哪一段文字滚到屏幕中间,就返回它的序号。 * 用在整页上时 root 不传(默认跟着浏览器窗口);放在可滚动的盒子里时,传这个盒子。 * ------------------------------------------------------------- */ export function useActiveStep(count: number, root?: RefObject) { const refs = useRef<(HTMLElement | null)[]>([]); const [active, setActive] = useState(0); useEffect(() => { const io = new IntersectionObserver( (entries) => entries.forEach((e) => e.isIntersecting && setActive(Number((e.target as HTMLElement).dataset.step))), {root: root?.current ?? null, rootMargin: '-45% 0px -45% 0px'}, ); refs.current.slice(0, count).forEach((el) => el && io.observe(el)); return () => io.disconnect(); }, [count, root]); return {active, bind: (i: number) => ({'data-step': i, ref: (el: HTMLElement | null) => { refs.current[i] = el; }})}; } /* ---------------- 示例:手机产品页 ---------------- */ const STEPS = [ {title: '拍得更清楚', sub: '4800 万像素主摄,夜景也干净。', icon: Camera, color: 'from-sky-500 to-indigo-700', stat: '48MP'}, {title: '续航一整天', sub: '38 小时视频播放,出门不用带充电宝。', icon: BatteryCharging, color: 'from-emerald-400 to-teal-800', stat: '38h'}, {title: '轻到忘记它', sub: '165 克机身,握在手里刚刚好。', icon: Feather, color: 'from-rose-400 to-fuchsia-800', stat: '165g'}, ]; export default function ScrollStoryDemo() { const box = useRef(null); const {active, bind} = useActiveStep(STEPS.length, box); return (
{STEPS.map((s, i) => (

{s.title}

{s.sub}

))}
{/* 固定不动的手机 */}
{STEPS.map((s, i) => (
{s.stat}
))}
{STEPS.map((s, i) => ( ))}
); } ```