# 点阵波纹 Dot Grid > 满屏整齐的小点,鼠标经过时被推开、亮起来,点一下有一圈冲击波扩散出去。 分类:动效 · 角色:主角(占一整块、抢全部注意力,一页最多一个) [全部组件和流程](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 的首屏背景。 - 不适合:已经有图片或视频背景;温暖、自然风格;后台。 - 适合的网站:SaaS、官网、活动页、作品集 - 放在:首屏、背景 - 手机上:好用:点一下有冲击波;没有鼠标推开。 - 性能代价:中:Canvas 每帧画所有点,点距别小于 20,滚出屏幕会暂停。 - 常搭配:[乱码解码 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,不用另外安装 - 文件:原样存成 `dot-grid.tsx`(比如 `src/components/dot-grid.tsx`)。能跑命令就下载[原文](https://www.dachuiai.com/code/dot-grid.tsx),比手抄准:`curl -fsSL -o src/components/dot-grid.tsx https://www.dachuiai.com/code/dot-grid.tsx`(Windows 写 `curl.exe`)。下载完看一眼第一行是 `import`,不是网页。 - 导出:`DotGrid` 是可以直接用的部分;最后的 `export default` 是演示示例,只作参考。 - 保留代码里 `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` | | 包在里面的内容 | | `gap` | `26` | 点和点的间距(px),别小于 20 | | `color` | `'#4A5472'` | 点平时的颜色 | | `glow` | `'#7DD3FC'` | 鼠标经过时点亮起来的颜色 | | `background` | `'#090B12'` | 背景色 | | `className` | `''` | 加在最外层的类名,用来调大小、位置、间距 | ## 你可以说 > 首屏背景做成 Dot Grid 点阵波纹:满屏小点,鼠标经过时被推开、亮起来,点一下有一圈冲击波扩散出去。 ## 完整代码 ```tsx import {useEffect, useRef, type ReactNode} from 'react'; /* --------------------------------------------------------------- * DotGrid 点阵波纹 * 满屏整齐的小点,鼠标经过时被推开、亮起来;点一下,一圈冲击波扩散出去。 * 每个点都像挂在弹簧上,推开后自己弹回原位。 * 纯 Canvas,滚出屏幕时自动暂停。 * ------------------------------------------------------------- */ type Props = {children?: ReactNode; gap?: number; color?: string; glow?: string; background?: string; className?: string}; const hex = (s: string) => [1, 3, 5].map((i) => parseInt(s.slice(i, i + 2), 16)); export function DotGrid({children, gap = 26, color = '#4A5472', glow = '#7DD3FC', background = '#090B12', className = ''}: Props) { const host = useRef(null); const ref = useRef(null); useEffect(() => { const c = ref.current!, ctx = c.getContext('2d')!, el = host.current!; const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches; const [r0, g0, b0] = hex(color), [r1, g1, b1] = hex(glow); let W = 0, H = 0, cols = 0, rows = 0, raf = 0, running = false; let ox = new Float32Array(0), oy = new Float32Array(0), vx = new Float32Array(0), vy = new Float32Array(0), heat = new Float32Array(0); const mouse = {x: -9999, y: -9999, px: -9999, py: -9999}; const waves: {x: number; y: number; r: number}[] = []; const build = () => { cols = Math.ceil(W / gap) + 1; rows = Math.ceil(H / gap) + 1; const n = cols * rows; ox = new Float32Array(n); oy = new Float32Array(n); vx = new Float32Array(n); vy = new Float32Array(n); heat = new Float32Array(n); }; const draw = () => { ctx.fillStyle = background; ctx.fillRect(0, 0, W, H); const sx = (W - (cols - 1) * gap) / 2, sy = (H - (rows - 1) * gap) / 2; for (let y = 0, i = 0; y < rows; y++) for (let x = 0; x < cols; x++, i++) { const h = Math.min(1, heat[i]); ctx.fillStyle = `rgb(${r0 + (r1 - r0) * h | 0},${g0 + (g1 - g0) * h | 0},${b0 + (b1 - b0) * h | 0})`; const d = 1.6 + h * 2.2; ctx.fillRect(sx + x * gap + ox[i] - d / 2, sy + y * gap + oy[i] - d / 2, d, d); } }; const tick = () => { raf = requestAnimationFrame(tick); const sx = (W - (cols - 1) * gap) / 2, sy = (H - (rows - 1) * gap) / 2; const mvx = mouse.x - mouse.px, mvy = mouse.y - mouse.py; mouse.px = mouse.x; mouse.py = mouse.y; for (const w of waves) w.r += 8; while (waves.length && waves[0].r > Math.hypot(W, H)) waves.shift(); for (let y = 0, i = 0; y < rows; y++) for (let x = 0; x < cols; x++, i++) { const px = sx + x * gap + ox[i], py = sy + y * gap + oy[i]; const dx = px - mouse.x, dy = py - mouse.y, d = Math.hypot(dx, dy); if (d < 130) { const f = (1 - d / 130) ** 2; vx[i] += (dx / (d + 1)) * f * 2.2 + mvx * f * 0.05; vy[i] += (dy / (d + 1)) * f * 2.2 + mvy * f * 0.05; heat[i] = Math.max(heat[i], f * 1.2); } for (const w of waves) { const wx = px - w.x, wy = py - w.y, wd = Math.hypot(wx, wy), band = Math.abs(wd - w.r); if (band < 42) { const f = (1 - band / 42) * Math.max(0, 1 - w.r / 900); vx[i] += (wx / (wd + 1)) * f * 2.4; vy[i] += (wy / (wd + 1)) * f * 2.4; heat[i] = Math.max(heat[i], f); } } vx[i] = (vx[i] - ox[i] * 0.07) * 0.84; vy[i] = (vy[i] - oy[i] * 0.07) * 0.84; ox[i] += vx[i]; oy[i] += vy[i]; heat[i] *= 0.955; } draw(); }; const resize = () => { const r = c.getBoundingClientRect(), dpr = Math.min(devicePixelRatio, 2); W = r.width; H = r.height; c.width = W * dpr; c.height = H * dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); build(); draw(); }; const pos = (e: PointerEvent) => { const r = el.getBoundingClientRect(); return [e.clientX - r.left, e.clientY - r.top]; }; const move = (e: PointerEvent) => { [mouse.x, mouse.y] = pos(e); if (mouse.px < -999) { mouse.px = mouse.x; mouse.py = mouse.y; } }; const leave = () => { mouse.x = mouse.y = mouse.px = mouse.py = -9999; }; const click = (e: PointerEvent) => { const [x, y] = pos(e); waves.push({x, y, r: 0}); }; const ro = new ResizeObserver(resize); ro.observe(c); const io = new IntersectionObserver(([e]) => { if (e.isIntersecting && !running) { running = true; raf = requestAnimationFrame(tick); } else if (!e.isIntersecting) { running = false; cancelAnimationFrame(raf); } }); if (!reduce) io.observe(c); el.addEventListener('pointermove', move); el.addEventListener('pointerleave', leave); el.addEventListener('pointerdown', click); return () => { cancelAnimationFrame(raf); ro.disconnect(); io.disconnect(); el.removeEventListener('pointermove', move); el.removeEventListener('pointerleave', leave); el.removeEventListener('pointerdown', click); }; }, [gap, color, glow, background]); return (
{children}
); } /* ---------------- 示例:首屏背景 ---------------- */ export default function DotGridDemo() { return (

每个像素
都在回应你

一套给产品团队的交互设计系统

); } ```