# 横向滚动 Horizontal Scroll > 页面钉住,手往下滚,内容往右走,底部有进度条。 分类:动效 · 角色:主角(占一整块、抢全部注意力,一页最多一个) [全部组件和流程](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. 告诉用户用了什么、放在哪个文件、以后想改快慢、颜色、大小改哪个参数。 ## 什么时候用 - 适合:时间线、发展历程、一排作品,要按顺序一个个看。 - 不适合:文字很多要细读的内容;横向内容超过 8 屏;以手机访问为主的页面。 - 适合的网站:作品集、官网、活动页 - 放在:内容区 - 手机上:要改:上下滑变成左右走容易迷失;手机上改成竖排,或改成能用手指左右划的横向列表(scroll-snap)。 - 性能代价:低:sticky 加一个横向位移。 - 常搭配:[速度跑马灯 Velocity Marquee](https://www.dachuiai.com/c/velocity-marquee.md)、[滚动出现 Scroll Reveal](https://www.dachuiai.com/c/scroll-reveal.md) ## 装进项目 - 写法:React 18 以上 + TypeScript + Tailwind CSS v4,一个文件就能用,不引用别的组件。 - 依赖:只用 react,不用另外安装 - 文件:原样存成 `horizontal-scroll.tsx`(比如 `src/components/horizontal-scroll.tsx`)。能跑命令就下载[原文](https://www.dachuiai.com/code/horizontal-scroll.tsx),比手抄准:`curl -fsSL -o src/components/horizontal-scroll.tsx https://www.dachuiai.com/code/horizontal-scroll.tsx`(Windows 写 `curl.exe`)。下载完看一眼第一行是 `import`,不是网页。 - 导出:`HorizontalScroll` 是可以直接用的部分;最后的 `export default` 是演示示例,只作参考。 - 图片:示例用了本站的 [/art/dunes.jpg](https://www.dachuiai.com/art/dunes.jpg)、[/art/ocean.jpg](https://www.dachuiai.com/art/ocean.jpg)、[/art/peaks.jpg](https://www.dachuiai.com/art/peaks.jpg)、[/art/sunset.jpg](https://www.dachuiai.com/art/sunset.jpg)、[/art/aurora.jpg](https://www.dachuiai.com/art/aurora.jpg)。换成用户自己的图;只想先看效果,可以从本站同样的路径下载,放进项目的 public 目录。 - 钉住效果靠 `position: sticky`:外面任何一层写了 `overflow: hidden` 或 `overflow-x: hidden`(常见在 body、main 上)都会失效,要防横向滚动条改用 `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、有赞这类建站平台装不进代码,告诉用户去平台自带的动效设置里找。 ## 参数 | 参数 | 默认值 | 作用 | | --- | --- | --- | | `children` | | 包在里面的内容 | | `height` | `620` | 高(px) | | `scroller` | | 滚动盒子的 ref;整页滚动时不传 | | `onProgress` | | 进度变化时调用,参数是 0 到 1 | ## 你可以说 > 这段时间线做成 Horizontal Scroll 横向滚动:页面钉住,手往下滚,内容往右走,底部有进度条。 ## 完整代码 ```tsx import {useEffect, useLayoutEffect, useRef, useState, type ReactNode, type RefObject} from 'react'; /* --------------------------------------------------------------- * HorizontalScroll 横向滚动 * 手往下滚,页面往右走。适合时间线和作品展示。 * 做法:外层很高、里面一层 sticky 钉住,把竖向滚动的进度换算成横向平移。 * 外层高度 = 横向要走的距离 + 一屏,这样滚动速度和手感是一比一的。 * 整页用时 scroller 不传;放在可滚动的盒子里时,传这个盒子。 * ------------------------------------------------------------- */ type Props = {children: ReactNode; height?: number; scroller?: RefObject; onProgress?: (p: number) => void}; export function HorizontalScroll({children, height = 620, scroller, onProgress}: Props) { const outer = useRef(null); const track = useRef(null); const [distance, setDistance] = useState(0); const cb = useRef(onProgress); cb.current = onProgress; useLayoutEffect(() => { const ro = new ResizeObserver(() => setDistance(Math.max(0, track.current!.scrollWidth - outer.current!.clientWidth))); ro.observe(track.current!); ro.observe(outer.current!); return () => ro.disconnect(); }, []); useEffect(() => { const el = scroller?.current; const target = el ?? window; let raf = 0, cur = 0, goal = 0; const tick = () => { const rail = track.current; if (!rail) { raf = 0; return; } cur += (goal - cur) * (matchMedia('(prefers-reduced-motion: reduce)').matches ? 1 : 0.14); if (Math.abs(goal - cur) < 0.0003) cur = goal; rail.style.transform = `translate3d(${-cur * distance}px, 0, 0)`; rail.style.setProperty('--hs', String(cur)); cb.current?.(cur); raf = cur === goal ? 0 : requestAnimationFrame(tick); }; const read = () => { const section = outer.current; if (!section) return; const top = section.getBoundingClientRect().top - (el ? el.getBoundingClientRect().top : 0); goal = distance ? Math.min(1, Math.max(0, -top / distance)) : 0; if (!raf) raf = requestAnimationFrame(tick); }; target.addEventListener('scroll', read, {passive: true}); read(); return () => { target.removeEventListener('scroll', read); cancelAnimationFrame(raf); }; }, [scroller, distance]); return (
{children}
); } /* ---------------- 示例:一条旅行时间线 ---------------- */ const STOPS = [ {year: '03 月', title: '沙丘', text: '在风里走了七天', img: '/art/dunes.jpg'}, {year: '06 月', title: '蓝洞', text: '第一次下潜到四十米', img: '/art/ocean.jpg'}, {year: '08 月', title: '群山', text: '住在山脊上的小木屋', img: '/art/peaks.jpg'}, {year: '10 月', title: '落日', text: '每天傍晚同一条金线', img: '/art/sunset.jpg'}, {year: '12 月', title: '极光', text: '零下二十度的晴夜', img: '/art/aurora.jpg'}, ]; export default function HorizontalScrollDemo() { const box = useRef(null); const bar = useRef(null); const n = STOPS.length; return (
{ if (bar.current) bar.current.style.transform = `scaleX(${p})`; }}>

这一年
去过的
地方

{STOPS.map((s, i) => (
{/* 图片比框宽,走的时候在框里反向慢慢移动,多一层视差 */} {s.title}
{s.year} {s.title} {s.text}
))}

明年,
再走远一点

); } ```