# 图片拖尾 Image Trail > 鼠标划过时一路留下一串图片,每张弹出来停一下再落下消失。 分类:动效 · 角色:主角(占一整块、抢全部注意力,一页最多一个) [全部组件和流程](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、后台、内容站;图片少于 6 张;同一页已经有光标跟随。 - 适合的网站:作品集、活动页、官网 - 放在:首屏 - 手机上:要改:手机上没有鼠标,几乎看不到效果;手机上换成一张主图。 - 性能代价:低:循环使用一组图片元素;图片要压缩。 - 常搭配:[乱码解码 Scramble Text](https://www.dachuiai.com/c/scramble-text.md)、[跑马灯 Marquee](https://www.dachuiai.com/c/marquee.md) ## 装进项目 - 写法:React 18 以上 + TypeScript + Tailwind CSS v4,一个文件就能用,不引用别的组件。 - 依赖:只用 react,不用另外安装 - 文件:原样存成 `image-trail.tsx`(比如 `src/components/image-trail.tsx`)。能跑命令就下载[原文](https://www.dachuiai.com/code/image-trail.tsx),比手抄准:`curl -fsSL -o src/components/image-trail.tsx https://www.dachuiai.com/code/image-trail.tsx`(Windows 写 `curl.exe`)。下载完看一眼第一行是 `import`,不是网页。 - 导出:`ImageTrail` 是可以直接用的部分;最后的 `export default` 是演示示例,只作参考。 - 图片:示例用了本站的图片按名字拼出的 `/art/<名字>.jpg`。换成用户自己的图;只想先看效果,可以从本站同样的路径下载,放进项目的 public 目录。 - 注意:拖出来的图会盖在首屏文字上面(故意的,不挡点击)。images 里不能有重复的地址。手机上没有鼠标:在里面放一张静态大图,用 [@media(hover:none)]:block 只在没鼠标的设备上显示。 - `images` 要写成组件外面的常量,或者用 useMemo 包起来:每次渲染都传一个新写的数组,组件会以为内容变了,从头重来。 - 保留代码里 `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、有赞这类建站平台装不进代码,告诉用户去平台自带的动效设置里找。 ## 参数 | 参数 | 默认值 | 作用 | | --- | --- | --- | | `images` | | 图片地址列表 | | `children` | | 包在里面的内容 | | `distance` | `110` | 鼠标移动多少像素放下一张图,越大越稀 | | `width` | `210` | 每张图的宽(px) | | `height` | `150` | 每张图的高(px) | | `className` | `''` | 加在最外层的类名,用来调大小、位置、间距 | ## 你可以说 > 首屏加 Image Trail 图片拖尾:鼠标划过时一路留下一串图片,每张弹出来停一下再落下消失。 ## 完整代码 ```tsx import {useEffect, useRef, type ReactNode} from 'react'; /* --------------------------------------------------------------- * ImageTrail 图片拖尾 * 鼠标划过,一路留下一串图片,每张弹出来、停一下、再落下消失。 * 只有移动超过一定距离才放下一张,所以快慢都不会糊成一团。 * 图片是一组循环使用的元素,不会越积越多。 * ------------------------------------------------------------- */ type Props = {images: string[]; children?: ReactNode; distance?: number; width?: number; height?: number; className?: string}; export function ImageTrail({images, children, distance = 110, width = 210, height = 150, className = ''}: Props) { const root = useRef(null); const pool = useRef<(HTMLImageElement | null)[]>([]); useEffect(() => { const el = root.current!; if (matchMedia('(prefers-reduced-motion: reduce)').matches) return; let i = 0, z = 1, lx = 0, ly = 0, first = true; const drop = (x: number, y: number, dx: number, dy: number) => { const img = pool.current[i++ % images.length]; if (!img) return; const tilt = (Math.random() - 0.5) * 16; img.style.zIndex = String(z++); img.getAnimations().forEach((a) => a.cancel()); img.animate( [ {opacity: 0, transform: `translate(${x - dx * 0.5 - width / 2}px, ${y - dy * 0.5 - height / 2}px) rotate(${tilt * 2}deg) scale(.55)`}, {opacity: 1, transform: `translate(${x - width / 2}px, ${y - height / 2}px) rotate(${tilt}deg) scale(1)`, offset: 0.16, easing: 'cubic-bezier(.22,1,.36,1)'}, {opacity: 1, transform: `translate(${x - width / 2}px, ${y - height / 2}px) rotate(${tilt}deg) scale(1)`, offset: 0.62, easing: 'cubic-bezier(.55,0,1,.45)'}, {opacity: 0, transform: `translate(${x - width / 2}px, ${y - height / 2 + 70}px) rotate(${tilt * 1.6}deg) scale(.82)`}, ], {duration: 1250, fill: 'both'}, ); }; const move = (e: PointerEvent) => { const r = el.getBoundingClientRect(), x = e.clientX - r.left, y = e.clientY - r.top; if (first) { first = false; lx = x; ly = y; return; } const dx = x - lx, dy = y - ly; if (Math.hypot(dx, dy) < distance) return; lx = x; ly = y; drop(x, y, dx, dy); }; el.addEventListener('pointermove', move); return () => el.removeEventListener('pointermove', move); }, [images, distance, width, height]); return (
{children} {images.map((src, i) => ( { pool.current[i] = el; }} src={src} alt="" draggable={false} className="pointer-events-none absolute left-0 top-0 rounded-2xl object-cover opacity-0 shadow-[0_24px_50px_-14px_rgba(0,0,0,.6)]" style={{width, height}} /> ))}
); } /* ---------------- 示例:作品集首屏 ---------------- */ const IMGS = ['planet', 'fluid-rose', 'dunes', 'aurora', 'lava', 'peaks', 'fluid-teal', 'ocean', 'silk-violet', 'sunset', 'ice', 'bokeh'].map((n) => `/art/${n}.jpg`); export default function ImageTrailDemo() { return (

一路走过
的地方

PHOTOGRAPHY 2026

); } ```