# 粒子聚合 Particle Morph > 几千个小点聚成文字或图标,停一会儿炸开,再聚成下一个;鼠标划过会把点推开。 分类:动效 · 角色:主角(占一整块、抢全部注意力,一页最多一个) [全部组件和流程](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. 告诉用户用了什么、放在哪个文件、以后想改快慢、颜色、大小改哪个参数。 ## 什么时候用 - 适合:科技感品牌、AI 产品、发布会的首屏主视觉,要一个让人记住的开场。 - 不适合:要马上看清文字和按钮的页面(聚合要一两秒);电商列表、内容站、后台;温暖手作风格。 - 适合的网站:官网、SaaS、活动页、作品集 - 放在:首屏 - 手机上:要改:点数从 7000 降到 2500 左右;手机上推不了点,只看自动轮播。 - 性能代价:高:Canvas 每帧画几千个点,滚出屏幕会暂停;同一页别再放第二个画布或 WebGL 动效。 - 常搭配:[乱码解码 Scramble Text](https://www.dachuiai.com/c/scramble-text.md)、[磁吸 Magnetic](https://www.dachuiai.com/c/magnetic.md) ## 装进项目 - 写法:React 18 以上 + TypeScript + Tailwind CSS v4,一个文件就能用,不引用别的组件。 - 依赖:只用 react,不用另外安装 - 文件:原样存成 `particle-morph.tsx`(比如 `src/components/particle-morph.tsx`)。能跑命令就下载[原文](https://www.dachuiai.com/code/particle-morph.tsx),比手抄准:`curl -fsSL -o src/components/particle-morph.tsx https://www.dachuiai.com/code/particle-morph.tsx`(Windows 写 `curl.exe`)。下载完看一眼第一行是 `import`,不是网页。 - 导出:`ParticleMorph` 是可以直接用的部分;最后的 `export default` 是演示示例,只作参考。 - 注意:shapes 里每个图形可以是文字(text),也可以是 24×24 画布上的 SVG 路径(path),lucide 图标的 path 可以直接用。 - `shapes` 要写成组件外面的常量,或者用 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、有赞这类建站平台装不进代码,告诉用户去平台自带的动效设置里找。 ## 参数 | 参数 | 默认值 | 作用 | | --- | --- | --- | | `shapes` | | 要聚成的图形:文字(text)或 24×24 的 SVG 路径(path) | | `count` | `7000` | 小点的数量,手机上减到 2500 左右 | | `interval` | `3800` | 自动切换的间隔(毫秒),传 0 不自动切换 | | `background` | `'#05060F'` | 背景色 | | `index` | | 当前显示第几个;不传就自己轮播 | | `onChange` | | 切换时调用,参数是新选中的那一项 | | `className` | `''` | 加在最外层的类名,用来调大小、位置、间距 | ## 你可以说 > 首屏做成 Particle Morph 粒子聚合:几千个小点聚成一个图形,停一会儿炸开,再聚成下一个图形;鼠标划过时把点推开。 ## 完整代码 ```tsx import {useEffect, useRef, useState} from 'react'; /* --------------------------------------------------------------- * ParticleMorph 粒子聚合 * 成千上万个小点聚成一个图形,再散开变成下一个。鼠标划过会把点推开。 * 图形可以是文字(text),也可以是 24×24 画布上的 SVG 路径(path)。 * 纯 Canvas,不需要 WebGL。滚出屏幕时自动暂停。 * ------------------------------------------------------------- */ export type MorphShape = {name: string; text?: string; path?: string; colors: [string, string, string]}; type Props = {shapes: MorphShape[]; count?: number; interval?: number; background?: string; index?: number; onChange?: (i: number) => void; className?: string}; const BUCKETS = 10; // 把图形画到一张隐藏的画布上,读出所有被涂到的像素,当作粒子的目的地 function samplePoints(shape: MorphShape, w: number, h: number, count: number) { const c = document.createElement('canvas'); c.width = w; c.height = h; const g = c.getContext('2d', {willReadFrequently: true})!; g.fillStyle = '#fff'; if (shape.path) { const size = Math.min(w, h) * 0.8; g.translate((w - size) / 2, (h - size) / 2); g.scale(size / 24, size / 24); g.fill(new Path2D(shape.path), 'evenodd'); } else { g.font = `900 ${Math.min(h * 0.72, (w * 1.5) / Math.max(1, shape.text!.length))}px "PingFang SC", "Helvetica Neue", Arial, sans-serif`; g.textAlign = 'center'; g.textBaseline = 'middle'; g.fillText(shape.text!, w / 2, h / 2); } const {data} = g.getImageData(0, 0, w, h); const hits: number[] = []; for (let y = 0; y < h; y += 2) for (let x = 0; x < w; x += 2) if (data[(y * w + x) * 4 + 3] > 128) hits.push(x, y); const out = new Float32Array(count * 2); const n = hits.length / 2; for (let i = 0; i < count; i++) { const k = ((Math.random() * n) | 0) * 2; out[i * 2] = hits[k] + Math.random() * 2; out[i * 2 + 1] = hits[k + 1] + Math.random() * 2; } return out; } const hexRgb = (s: string) => [1, 3, 5].map((i) => parseInt(s.slice(i, i + 2), 16)); function palette([a, b, c]: [string, string, string]) { const [A, B, C] = [a, b, c].map(hexRgb); return Array.from({length: BUCKETS}, (_, i) => { const t = i / (BUCKETS - 1), u = t < 0.5 ? t * 2 : (t - 0.5) * 2, [P, Q] = t < 0.5 ? [A, B] : [B, C]; return `rgb(${P.map((v, k) => Math.round(v + (Q[k] - v) * u)).join(',')})`; }); } export function ParticleMorph({shapes, count = 7000, interval = 3800, background = '#05060F', index, onChange, className = ''}: Props) { const ref = useRef(null); const [auto, setAuto] = useState(0); const cur = index ?? auto; const api = useRef<{morph: (i: number) => void} | null>(null); const change = useRef(onChange); change.current = onChange; useEffect(() => { const canvas = ref.current!; const ctx = canvas.getContext('2d')!; const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches; const px = new Float32Array(count), py = new Float32Array(count), vx = new Float32Array(count), vy = new Float32Array(count); const size = new Float32Array(count).map(() => 0.8 + Math.random() * 1.1); let targets = new Float32Array(count * 2), hold = new Float32Array(count), colors = palette(shapes[0].colors); let W = 0, H = 0, current = 0, raf = 0, running = false, born = false; const mouse = {x: -9999, y: -9999}; const setTargets = (i: number, burst: boolean) => { targets = samplePoints(shapes[i], W, H, count); colors = palette(shapes[i].colors); if (!burst) return; for (let k = 0; k < count; k++) { // 先炸开再聚拢:往外推一把,再错开一点时间才开始回收 const a = Math.random() * Math.PI * 2, s = 3 + Math.random() * 11; vx[k] += Math.cos(a) * s; vy[k] += Math.sin(a) * s; hold[k] = Math.random() * 26; } }; const draw = (fade: number) => { ctx.globalCompositeOperation = 'source-over'; ctx.globalAlpha = fade; ctx.fillStyle = background; ctx.fillRect(0, 0, W, H); ctx.globalAlpha = 1; ctx.globalCompositeOperation = 'lighter'; ctx.globalAlpha = 0.8; // 按高度分桶上色:每种颜色只切换一次 fillStyle,几千个点也不卡 for (let b = 0; b < BUCKETS; b++) { ctx.fillStyle = colors[b]; const lo = (H * b) / BUCKETS, hi = (H * (b + 1)) / BUCKETS; for (let i = 0; i < count; i++) { const y = targets[i * 2 + 1]; if ((y >= lo && y < hi) || (b === 0 && y < 0)) ctx.fillRect(px[i], py[i], size[i], size[i]); } } }; const tick = () => { raf = requestAnimationFrame(tick); for (let i = 0; i < count; i++) { if (hold[i] > 0) hold[i]--; else { vx[i] += (targets[i * 2] - px[i]) * 0.022; vy[i] += (targets[i * 2 + 1] - py[i]) * 0.022; } const dx = px[i] - mouse.x, dy = py[i] - mouse.y, d2 = dx * dx + dy * dy; if (d2 < 8100) { const f = (1 - d2 / 8100) * 2.4 / (Math.sqrt(d2) + 1); vx[i] += dx * f; vy[i] += dy * f; } vx[i] *= 0.86; vy[i] *= 0.86; px[i] += vx[i]; py[i] += vy[i]; } draw(0.3); }; const start = () => { if (!running && !reduce) { running = true; raf = requestAnimationFrame(tick); } }; const stop = () => { running = false; cancelAnimationFrame(raf); }; const resize = () => { const r = canvas.getBoundingClientRect(), dpr = Math.min(devicePixelRatio, 2); W = Math.round(r.width); H = Math.round(r.height); if (!W || !H) return; canvas.width = W * dpr; canvas.height = H * dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); setTargets(current, false); if (!born) { born = true; // 第一帧就有内容:开场时粒子撒满画面,再聚起来 for (let i = 0; i < count; i++) { px[i] = Math.random() * W; py[i] = Math.random() * H; hold[i] = Math.random() * 30; } } if (reduce) { px.set(targets.filter((_, i) => i % 2 === 0)); py.set(targets.filter((_, i) => i % 2 === 1)); draw(1); } }; api.current = { morph: (i) => { current = i; if (!W) return; setTargets(i, !reduce); if (reduce) { px.set(targets.filter((_, k) => k % 2 === 0)); py.set(targets.filter((_, k) => k % 2 === 1)); draw(1); } }, }; const ro = new ResizeObserver(resize); ro.observe(canvas); const io = new IntersectionObserver(([e]) => (e.isIntersecting ? start() : stop())); io.observe(canvas); const move = (e: PointerEvent) => { const r = canvas.getBoundingClientRect(); mouse.x = e.clientX - r.left; mouse.y = e.clientY - r.top; }; const leave = () => { mouse.x = mouse.y = -9999; }; canvas.addEventListener('pointermove', move); canvas.addEventListener('pointerleave', leave); return () => { stop(); ro.disconnect(); io.disconnect(); canvas.removeEventListener('pointermove', move); canvas.removeEventListener('pointerleave', leave); api.current = null; }; }, [shapes, count, background]); // 切换图形 const last = useRef(cur); useEffect(() => { if (last.current === cur) return; last.current = cur; api.current?.morph(cur); }, [cur]); // 自动轮播 useEffect(() => { if (!interval || matchMedia('(prefers-reduced-motion: reduce)').matches) return; const t = setTimeout(() => { const next = (cur + 1) % shapes.length; setAuto(next); change.current?.(next); }, interval); return () => clearTimeout(t); }, [cur, interval, shapes.length]); return ; } /* ---------------- 示例:放在首屏当主角 ---------------- */ const SHAPES: MorphShape[] = [ {name: 'AI', text: 'AI', colors: ['#38E8FF', '#5B6CFF', '#FF3FC8']}, {name: '星球', path: 'M12 5.2a6.8 6.8 0 1 0 0 13.6 6.8 6.8 0 0 0 0-13.6Z M1.2 15.7c-.9 1.6 2 2.3 6.1 1.4 2.6-.6 5.6-1.7 8.5-3.3 2.9-1.7 5.3-3.6 6.6-5.3 1.1-1.5.9-2.4-.8-2.4-.6 0-1.3.1-2.1.3l.5.9c1.3-.2 1.9-.1 1.5.5-.9 1.4-3.1 3.2-5.9 4.8-2.8 1.6-5.6 2.7-7.9 3.2-2.3.5-4 .4-4-.2l-.3-.6-2.2.4Z', colors: ['#FFC24D', '#FF5A36', '#A62BFF']}, {name: '闪电', path: 'M13.5 1.5 3.8 13.6h6.5l-1.8 8.9 9.7-12.1h-6.5l1.8-8.9Z', colors: ['#FFE34D', '#FFAE1F', '#FF4F1F']}, {name: '爱心', path: 'M12 21.3S2.2 15.2 2.2 8.6A5.4 5.4 0 0 1 12 5.5a5.4 5.4 0 0 1 9.8 3.1c0 6.6-9.8 12.7-9.8 12.7Z', colors: ['#FF7AA8', '#FF2E7A', '#9A1FFF']}, {name: '词', text: '词', colors: ['#6DFFC0', '#14DFA0', '#0F8BFF']}, ]; export default function ParticleMorphDemo() { const [i, setI] = useState(0); return (
{SHAPES.map((s, k) => ( ))}
); } ```