# 无限画廊 Infinite Gallery > 一整面图片墙,往哪个方向拖都没有尽头,松手后有惯性。 分类:动效 · 角色:主角(占一整块、抢全部注意力,一页最多一个) [全部组件和流程](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. 告诉用户用了什么、放在哪个文件、以后想改快慢、颜色、大小改哪个参数。 ## 什么时候用 - 适合:作品集、摄影、灵感图库,图片多,想让人随便逛。 - 不适合:要搜索、筛选、按顺序看的商品列表;图片少于 12 张(一眼看出重复)。 - 适合的网站:作品集、活动页、官网 - 放在:首屏、内容区 - 手机上:要改:拖动会和页面上下滑抢手势;手机上让它占满一整屏单独成页,或者换成普通网格。 - 性能代价:中:一直在缓慢漂移,图片又多,图片要压缩到每张 200KB 以内。 - 常搭配:[光标跟随 Custom Cursor](https://www.dachuiai.com/c/custom-cursor.md)、[共享元素转场 Shared Element Transition](https://www.dachuiai.com/c/shared-element.md) ## 装进项目 - 写法:React 18 以上 + TypeScript + Tailwind CSS v4,一个文件就能用,不引用别的组件。 - 依赖:只用 react,不用另外安装 - 文件:原样存成 `infinite-gallery.tsx`(比如 `src/components/infinite-gallery.tsx`)。能跑命令就下载[原文](https://www.dachuiai.com/code/infinite-gallery.tsx),比手抄准:`curl -fsSL -o src/components/infinite-gallery.tsx https://www.dachuiai.com/code/infinite-gallery.tsx`(Windows 写 `curl.exe`)。下载完看一眼第一行是 `import`,不是网页。 - 导出:`InfiniteGallery` 是可以直接用的部分;最后的 `export default` 是演示示例,只作参考。 - 图片:示例用了本站的图片按名字拼出的 `/art/<名字>.jpg`。换成用户自己的图;只想先看效果,可以从本站同样的路径下载,放进项目的 public 目录。 - 保留代码里 `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、有赞这类建站平台装不进代码,告诉用户去平台自带的动效设置里找。 ## 参数 | 参数 | 默认值 | 作用 | | --- | --- | --- | | `items` | | 要显示的一组内容,格式见代码里的类型 | | `tileW` | `250` | 每张图的宽(px) | | `tileH` | `320` | 每张图的高(px) | | `gap` | `18` | 间距(px) | | `drift` | `0.35` | 不拖的时候自己漂的速度,0 不漂 | | `className` | `''` | 加在最外层的类名,用来调大小、位置、间距 | ## 你可以说 > 作品页做成 Infinite Gallery 无限画廊:一整面图片墙,往任何方向拖都没有尽头,松手后有惯性。 ## 完整代码 ```tsx import {useEffect, useRef} from 'react'; /* --------------------------------------------------------------- * InfiniteGallery 无限画廊 * 一整面图片墙,往哪拖都没有尽头,松手还会滑一段。 * 做法:只摆"比屏幕大一圈"的一组图片,用取余让滑出去的图从另一边回来。 * 全程用 requestAnimationFrame 直接改 transform,不走 React 渲染,所以很顺。 * 键盘方向键也能移动。 * ------------------------------------------------------------- */ type Item = {src: string; title: string}; type Props = {items: Item[]; tileW?: number; tileH?: number; gap?: number; drift?: number; className?: string}; const mod = (a: number, n: number) => ((a % n) + n) % n; export function InfiniteGallery({items, tileW = 250, tileH = 320, gap = 18, drift = 0.35, className = ''}: Props) { const stage = useRef(null); const tiles = useRef<(HTMLDivElement | null)[]>([]); const COLS = 7, ROWS = 4; // 要比可视区域多出至少一行一列 const cw = tileW + gap, ch = tileH + gap, totalW = COLS * cw, totalH = ROWS * ch; useEffect(() => { const el = stage.current!; const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches; const s = {x: 0, y: 0, vx: reduce ? 0 : -drift, vy: 0, down: false, lx: 0, ly: 0, hold: 0, wide: 0}; let raf = 0, running = false; const paint = () => { const k = Math.min(1, Math.hypot(s.vx, s.vy) / 40); // 甩得越快,图片缩得越小,像被拉开 for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) { const t = tiles.current[r * COLS + c]; if (!t) continue; const px = mod(c * cw + s.x, totalW) - cw; const stagger = c % 2 ? ch / 2 : 0; // 单双列错开半格 const py = mod(r * ch + s.y + stagger, totalH) - ch; t.style.transform = `translate3d(${px}px, ${py}px, 0) scale(${1 - k * 0.07})`; } }; const tick = () => { raf = requestAnimationFrame(tick); if (!s.down) { s.x += s.vx; s.y += s.vy; const idle = reduce ? 0 : -drift; s.vx += (idle - s.vx) * 0.05; // 松手后的惯性慢慢回到缓慢漂移 s.vy *= 0.95; } paint(); }; const start = () => { if (!running) { running = true; raf = requestAnimationFrame(tick); } }; const stop = () => { running = false; cancelAnimationFrame(raf); }; const down = (e: PointerEvent) => { s.down = true; s.lx = e.clientX; s.ly = e.clientY; s.vx = s.vy = 0; el.setPointerCapture(e.pointerId); el.dataset.grab = '1'; }; const move = (e: PointerEvent) => { if (!s.down) return; const dx = e.clientX - s.lx, dy = e.clientY - s.ly; s.lx = e.clientX; s.ly = e.clientY; s.x += dx; s.y += dy; s.vx = s.vx * 0.6 + dx * 0.4; s.vy = s.vy * 0.6 + dy * 0.4; }; const up = () => { s.down = false; delete el.dataset.grab; }; const key = (e: KeyboardEvent) => { const v = {ArrowLeft: [24, 0], ArrowRight: [-24, 0], ArrowUp: [0, 24], ArrowDown: [0, -24]}[e.key]; if (!v) return; e.preventDefault(); s.vx = v[0]; s.vy = v[1]; }; el.addEventListener('pointerdown', down); el.addEventListener('pointermove', move); el.addEventListener('pointerup', up); el.addEventListener('pointercancel', up); el.addEventListener('keydown', key); paint(); const io = new IntersectionObserver(([e]) => (e.isIntersecting ? start() : stop())); io.observe(el); return () => { stop(); io.disconnect(); el.removeEventListener('pointerdown', down); el.removeEventListener('pointermove', move); el.removeEventListener('pointerup', up); el.removeEventListener('pointercancel', up); el.removeEventListener('keydown', key); }; }, [cw, ch, totalW, totalH, drift]); return (
{Array.from({length: ROWS * COLS}, (_, i) => { // 打散顺序,相邻的图不重复 const it = items[(i * 5 + Math.floor(i / COLS) * 3) % items.length]; return (
{ tiles.current[i] = el; }} className="ig-tile group absolute left-0 top-0 overflow-hidden rounded-2xl will-change-transform" style={{width: tileW, height: tileH}}> {it.title}
{it.title}
); })}
); } /* ---------------- 示例:一面摄影作品墙 ---------------- */ const ART: Item[] = [ ['planet', '远行星'], ['fluid-rose', '颜料 01'], ['dunes', '沙丘'], ['aurora', '极光'], ['lava', '地火'], ['peaks', '晨山'], ['fluid-teal', '颜料 02'], ['ocean', '光束'], ['silk-violet', '紫绸'], ['sunset', '落日'], ['ice', '蓝冰'], ['forest', '雾林'], ['bokeh', '夜色'], ['silk-amber', '琥珀'], ].map(([n, title]) => ({src: `/art/${n}.jpg`, title})); export default function InfiniteGalleryDemo() { return (
); } ```