# 流体背景 Fluid Background > 几种颜色像岩浆灯一样慢慢流动的背景,鼠标移动时微微偏转。 分类:动效 · 角色:主角(占一整块、抢全部注意力,一页最多一个) [全部组件和流程](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 产品、音乐、美妆、活动页。 - 不适合:背景上要放大段文字;后台;同一页已经有别的 WebGL 或 Canvas 主角。 - 适合的网站:SaaS、官网、活动页、作品集 - 放在:首屏、背景 - 手机上:好用:已经按一半分辨率渲染;老手机发热的话,手机上换成一张静态渐变图。 - 性能代价:中:WebGL 一直在画;开了减弱动态效果停在一帧。 - 常搭配:[磁吸 Magnetic](https://www.dachuiai.com/c/magnetic.md)、[乱码解码 Scramble Text](https://www.dachuiai.com/c/scramble-text.md)、[打字机 Typewriter](https://www.dachuiai.com/c/typewriter.md) ## 装进项目 - 写法:React 18 以上 + TypeScript + Tailwind CSS v4,一个文件就能用,不引用别的组件。 - 依赖:只用 react,不用另外安装 - 文件:原样存成 `fluid-background.tsx`(比如 `src/components/fluid-background.tsx`)。能跑命令就下载[原文](https://www.dachuiai.com/code/fluid-background.tsx),比手抄准:`curl -fsSL -o src/components/fluid-background.tsx https://www.dachuiai.com/code/fluid-background.tsx`(Windows 写 `curl.exe`)。下载完看一眼第一行是 `import`,不是网页。 - 导出:`FluidBackground` 是可以直接用的部分;最后的 `export default` 是演示示例,只作参考。 - 注意:colors 传四个颜色,speed 调流速。 - `colors` 要写成组件外面的常量,或者用 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、有赞这类建站平台装不进代码,告诉用户去平台自带的动效设置里找。 ## 参数 | 参数 | 默认值 | 作用 | | --- | --- | --- | | `colors` | `DEFAULT` | 四个颜色 | | `speed` | `1` | 流动速度的倍数,1 是默认 | | `className` | `''` | 加在最外层的类名,用来调大小、位置、间距 | ## 你可以说 > 首屏背景换成 Fluid Background 流体背景:几种颜色像岩浆灯一样慢慢流动,鼠标移动时微微偏转,用 WebGL 着色器做。 ## 完整代码 ```tsx import {useEffect, useRef} from 'react'; /* --------------------------------------------------------------- * FluidBackground 流体背景 * 一整块颜色像岩浆灯一样慢慢流动,鼠标移动时跟着微微偏转。 * 用 WebGL 着色器画的,不用视频、不用图片,放大也不糊; * 为了省电,按一半分辨率渲染再放大(反正是柔和的渐变,看不出来)。 * colors 传四个颜色。开了"减弱动态效果"时停在一帧。 * ------------------------------------------------------------- */ const VERT = 'attribute vec2 p; void main(){ gl_Position = vec4(p, 0., 1.); }'; const FRAG = `precision highp float; uniform vec2 res, mouse; uniform float t; uniform vec3 c0, c1, c2, c3; float h(vec2 p){ return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); } float n(vec2 p){ vec2 i = floor(p), f = fract(p); f = f * f * (3. - 2. * f); return mix(mix(h(i), h(i + vec2(1, 0)), f.x), mix(h(i + vec2(0, 1)), h(i + vec2(1, 1)), f.x), f.y); } float fbm(vec2 p){ float v = 0., a = .5; for (int i = 0; i < 5; i++){ v += a * n(p); p = p * 2.02 + 7.; a *= .5; } return v; } void main(){ vec2 uv = gl_FragCoord.xy / res; vec2 p = uv * vec2(res.x / res.y, 1.) * 1.6; p += (mouse - .5) * .25; // 域扭曲:用噪声去扭曲噪声,流动感就是这么来的 vec2 q = vec2(fbm(p + t * .05), fbm(p + vec2(5.2, 1.3) - t * .04)); vec2 r = vec2(fbm(p + 3.5 * q + vec2(1.7, 9.2) + t * .07), fbm(p + 3.5 * q + vec2(8.3, 2.8) - t * .06)); float f = fbm(p + 3. * r); vec3 col = mix(c0, c1, smoothstep(.2, .6, f)); col = mix(col, c2, smoothstep(.35, .85, length(q))); col = mix(col, c3, smoothstep(.55, .95, r.x * f * 1.6)); col += .025 * (h(gl_FragCoord.xy + t) - .5); // 一点颗粒,避免色带 gl_FragColor = vec4(col, 1.); }`; const DEFAULT: [string, string, string, string] = ['#0B0B2B', '#3A1C9E', '#FF3D81', '#FFC56B']; const rgb = (s: string) => [1, 3, 5].map((i) => parseInt(s.slice(i, i + 2), 16) / 255); export function FluidBackground({colors = DEFAULT, speed = 1, className = ''}: {colors?: [string, string, string, string]; speed?: number; className?: string}) { const ref = useRef(null); useEffect(() => { const c = ref.current!; const gl = c.getContext('webgl', {antialias: false, alpha: false}); if (!gl) { c.style.background = `linear-gradient(135deg, ${colors[0]}, ${colors[1]}, ${colors[2]})`; return; } const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches; const sh = (type: number, src: string) => { const s = gl.createShader(type)!; gl.shaderSource(s, src); gl.compileShader(s); return s; }; const pr = gl.createProgram()!; gl.attachShader(pr, sh(gl.VERTEX_SHADER, VERT)); gl.attachShader(pr, sh(gl.FRAGMENT_SHADER, FRAG)); gl.linkProgram(pr); gl.useProgram(pr); gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer()); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW); const loc = gl.getAttribLocation(pr, 'p'); gl.enableVertexAttribArray(loc); gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0); const U = (s: string) => gl.getUniformLocation(pr, s); colors.forEach((col, i) => gl.uniform3fv(U(`c${i}`), rgb(col))); const m = {x: 0.5, y: 0.5, gx: 0.5, gy: 0.5}; let raf = 0, running = false, t = 40; const frame = () => { m.x += (m.gx - m.x) * 0.04; m.y += (m.gy - m.y) * 0.04; gl.uniform2f(U('res'), c.width, c.height); gl.uniform2f(U('mouse'), m.x, m.y); gl.uniform1f(U('t'), t); gl.drawArrays(gl.TRIANGLES, 0, 3); }; const tick = () => { raf = requestAnimationFrame(tick); t += 0.016 * speed; frame(); }; const resize = () => { const r = c.getBoundingClientRect(); c.width = Math.max(1, r.width * 0.5); c.height = Math.max(1, r.height * 0.5); gl.viewport(0, 0, c.width, c.height); frame(); }; const move = (e: PointerEvent) => { const r = c.getBoundingClientRect(); m.gx = (e.clientX - r.left) / r.width; m.gy = 1 - (e.clientY - r.top) / r.height; }; 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); const host = c.parentElement!; host.addEventListener('pointermove', move); return () => { cancelAnimationFrame(raf); ro.disconnect(); io.disconnect(); host.removeEventListener('pointermove', move); }; }, [colors, speed]); return ; } /* ---------------- 示例:首屏背景 ---------------- */ export default function FluidBackgroundDemo() { return (

让想法
流动起来

e.preventDefault()} className="mt-9 rounded-full bg-white px-8 py-3.5 text-sm font-bold text-[#1B0F5C] transition hover:scale-[1.04] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-white">立即开始
); } ```