motion-background

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Motion Background

动态背景

Ambient, living backgrounds that sit behind content without stealing focus. The goal is subtle, seamlessly looping motion that stays readable and performant. This skill is self-contained: every technique below ships its own runnable code — CSS, GLSL, and canvas — with no external dependencies beyond Three.js where a shader is involved.
作为内容背后的氛围型动态背景,不会抢夺视觉焦点。目标是实现微妙、无缝循环的动态效果,同时保持内容可读性与性能。此技能完全独立:以下每种技术都附带可直接运行的代码——CSS、GLSL和Canvas代码,除了使用着色器时依赖Three.js外,无其他外部依赖。

When to use

适用场景

  • Hero/landing background that subtly moves; section ambience.
  • Login/splash/empty-state living backdrops.
  • A gradient, mesh, aurora, shader, or particle/constellation field behind text or UI.
  • 微妙动态的Hero/着陆页背景;页面区块氛围营造。
  • 登录页/启动页/空状态的动态背景。
  • 文本或UI背后的渐变、网格、极光、着色器或粒子/星座效果。

Pick an approach

选择实现方案

LookTechniqueCost
Soft animated gradient/meshCSS gradients + keyframesCheapest, no JS
Flowing aurora / organic noiseGLSL fragment shader (Three.js full-screen quad)GPU, moderate
Constellation / drifting dotsCanvas 2D particlesCPU, scales with count
Depth / parallaxSame shader/particles with mouse-driven offsetModerate
Default to CSS if a gradient suffices — it is the cheapest and most reliable. Use a shader for organic flowing color; use canvas particles for a constellation/network look.
视觉效果实现技术性能成本
柔和动画渐变/网格CSS渐变 + keyframes最低,无需JS
流动极光/有机噪波GLSL片段着色器(Three.js全屏四边形)占用GPU,中等
星座/漂移点Canvas 2D粒子占用CPU,随粒子数量增加而上升
深度/视差结合鼠标驱动偏移的着色器/粒子效果中等
如果渐变效果足够,优先选择CSS方案——它成本最低且最可靠。需要有机流动色彩时使用着色器;需要星座/网络视觉效果时使用Canvas粒子。

Design rules

设计规则

  • Subtle: low contrast versus content, slow motion (long periods, 8–30s loops), nothing that competes with text.
  • Readable: keep contrast for foreground text; add a scrim (
    linear-gradient
    overlay or
    backdrop
    ) if needed.
  • Seamless loop: drive motion with periodic functions so time wraps with no visible jump (see below).
  • Respect motion preferences and battery: honor
    prefers-reduced-motion
    , and pause when offscreen or the tab is hidden.
  • 微妙低干扰:与内容对比度低,动画速度缓慢(循环周期8–30秒),不与文本争夺注意力。
  • 可读性保障:确保前景文本的对比度;必要时添加遮罩层(
    linear-gradient
    叠加层或
    backdrop
    )。
  • 无缝循环:使用周期函数驱动动画,使时间轴循环时无明显跳变(详见下文)。
  • 尊重动效偏好与电量:遵循
    prefers-reduced-motion
    设置,在背景移出视口或标签页隐藏时暂停动画。

Core techniques (inlined, runnable)

核心技术(内嵌可运行代码)

1. Animated CSS mesh gradient (no JS)

1. 动画CSS网格渐变(无需JS)

Layered radial gradients whose positions drift. Animating
background-position
on a larger-than-viewport gradient loops seamlessly.
css
.bg {
  position: fixed; inset: 0; z-index: -1;
  background:
    radial-gradient(40% 50% at 20% 30%, #5b8cff55, transparent 60%),
    radial-gradient(45% 55% at 80% 20%, #b05bff55, transparent 60%),
    radial-gradient(50% 60% at 50% 80%, #2de1c255, transparent 60%),
    #0b0e1a;
  background-size: 200% 200%;
  animation: meshmove 24s ease-in-out infinite;
}
@keyframes meshmove {
  0%, 100% { background-position: 0% 0%, 100% 0%, 50% 100%; }
  50%      { background-position: 30% 20%, 70% 30%, 60% 70%; }
}
@media (prefers-reduced-motion: reduce) {
  .bg { animation: none; }
}
The
0%
and
100%
keyframes are identical, so the loop has no seam.
通过分层径向渐变的位置偏移实现动画。在大于视口尺寸的渐变上动画
background-position
,实现无缝循环。
css
.bg {
  position: fixed; inset: 0; z-index: -1;
  background:
    radial-gradient(40% 50% at 20% 30%, #5b8cff55, transparent 60%),
    radial-gradient(45% 55% at 80% 20%, #b05bff55, transparent 60%),
    radial-gradient(50% 60% at 50% 80%, #2de1c255, transparent 60%),
    #0b0e1a;
  background-size: 200% 200%;
  animation: meshmove 24s ease-in-out infinite;
}
@keyframes meshmove {
  0%, 100% { background-position: 0% 0%, 100% 0%, 50% 100%; }
  50%      { background-position: 30% 20%, 70% 30%, 60% 70%; }
}
@media (prefers-reduced-motion: reduce) {
  .bg { animation: none; }
}
0%
100%
关键帧完全相同,因此循环无接缝。

2. Full-screen GLSL gradient + noise shader (Three.js)

2. 全屏GLSL渐变+噪波着色器(Three.js)

A flowing aurora/gradient using value noise in a fragment shader on a full-screen plane.
uTime
advances each frame; to loop, feed it a wrapped time (section 4).
js
import * as THREE from 'three';

const canvas = document.querySelector('#bg');
const renderer = new THREE.WebGLRenderer({canvas, antialias: true});
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); // cap for perf
const scene = new THREE.Scene();
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);

const uniforms = {
  uTime: {value: 0},
  uRes: {value: new THREE.Vector2()},
  uColorA: {value: new THREE.Color('#5b8cff')},
  uColorB: {value: new THREE.Color('#b05bff')},
};

const material = new THREE.ShaderMaterial({
  uniforms,
  vertexShader: `void main(){ gl_Position = vec4(position, 1.0); }`,
  fragmentShader: `
    precision highp float;
    uniform float uTime; uniform vec2 uRes;
    uniform vec3 uColorA, uColorB;

    // hash + value noise
    float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1,311.7)))*43758.5453); }
    float noise(vec2 p){
      vec2 i = floor(p), f = fract(p);
      float a = hash(i), b = hash(i+vec2(1,0));
      float c = hash(i+vec2(0,1)), d = hash(i+vec2(1,1));
      vec2 u = f*f*(3.0-2.0*f);
      return mix(mix(a,b,u.x), mix(c,d,u.x), u.y);
    }
    float fbm(vec2 p){
      float v=0.0, a=0.5;
      for(int i=0;i<5;i++){ v += a*noise(p); p*=2.0; a*=0.5; }
      return v;
    }
    void main(){
      vec2 uv = gl_FragCoord.xy / uRes.xy;
      float n = fbm(uv*3.0 + vec2(uTime*0.05, uTime*0.03));
      vec3 col = mix(uColorA, uColorB, smoothstep(0.2, 0.8, n + uv.y*0.3));
      gl_FragColor = vec4(col, 1.0);
    }`,
});

const quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), material);
scene.add(quad);

function resize(){
  renderer.setSize(window.innerWidth, window.innerHeight);
  uniforms.uRes.value.set(window.innerWidth, window.innerHeight);
}
window.addEventListener('resize', resize); resize();

const clock = new THREE.Clock();
let running = true;
function loop(){
  if (running){
    uniforms.uTime.value = clock.getElapsedTime();
    renderer.render(scene, camera);
  }
  requestAnimationFrame(loop);
}
loop();
在全屏平面上使用片段着色器中的值噪波实现流动极光/渐变效果。
uTime
逐帧递增;如需循环,传入包装后的时间值(见第4节)。
js
import * as THREE from 'three';

const canvas = document.querySelector('#bg');
const renderer = new THREE.WebGLRenderer({canvas, antialias: true});
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); // cap for perf
const scene = new THREE.Scene();
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);

const uniforms = {
  uTime: {value: 0},
  uRes: {value: new THREE.Vector2()},
  uColorA: {value: new THREE.Color('#5b8cff')},
  uColorB: {value: new THREE.Color('#b05bff')},
};

const material = new THREE.ShaderMaterial({
  uniforms,
  vertexShader: `void main(){ gl_Position = vec4(position, 1.0); }`,
  fragmentShader: `
    precision highp float;
    uniform float uTime; uniform vec2 uRes;
    uniform vec3 uColorA, uColorB;

    // hash + value noise
    float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1,311.7)))*43758.5453); }
    float noise(vec2 p){
      vec2 i = floor(p), f = fract(p);
      float a = hash(i), b = hash(i+vec2(1,0));
      float c = hash(i+vec2(0,1)), d = hash(i+vec2(1,1));
      vec2 u = f*f*(3.0-2.0*f);
      return mix(mix(a,b,u.x), mix(c,d,u.x), u.y);
    }
    float fbm(vec2 p){
      float v=0.0, a=0.5;
      for(int i=0;i<5;i++){ v += a*noise(p); p*=2.0; a*=0.5; }
      return v;
    }
    void main(){
      vec2 uv = gl_FragCoord.xy / uRes.xy;
      float n = fbm(uv*3.0 + vec2(uTime*0.05, uTime*0.03));
      vec3 col = mix(uColorA, uColorB, smoothstep(0.2, 0.8, n + uv.y*0.3));
      gl_FragColor = vec4(col, 1.0);
    }`,
});

const quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), material);
scene.add(quad);

function resize(){
  renderer.setSize(window.innerWidth, window.innerHeight);
  uniforms.uRes.value.set(window.innerWidth, window.innerHeight);
}
window.addEventListener('resize', resize); resize();

const clock = new THREE.Clock();
let running = true;
function loop(){
  if (running){
    uniforms.uTime.value = clock.getElapsedTime();
    renderer.render(scene, camera);
  }
  requestAnimationFrame(loop);
}
loop();

3. Canvas constellation particles

3. Canvas星座粒子

Drifting points connected by lines when near — the classic "network" background. Pure canvas 2D, no dependencies.
js
const canvas = document.querySelector('#stars');
const ctx = canvas.getContext('2d');
let W, H, pts;
const COUNT = 80, LINK = 120;

function init(){
  W = canvas.width = innerWidth; H = canvas.height = innerHeight;
  pts = Array.from({length: COUNT}, () => ({
    x: Math.random()*W, y: Math.random()*H,
    vx: (Math.random()-0.5)*0.3, vy: (Math.random()-0.5)*0.3,
  }));
}
addEventListener('resize', init); init();

function frame(){
  ctx.clearRect(0, 0, W, H);
  for (const p of pts){
    p.x += p.vx; p.y += p.vy;
    if (p.x<0||p.x>W) p.vx*=-1;
    if (p.y<0||p.y>H) p.vy*=-1;
    ctx.fillStyle = '#9db4ff'; ctx.fillRect(p.x, p.y, 2, 2);
  }
  for (let i=0;i<COUNT;i++) for (let j=i+1;j<COUNT;j++){
    const dx=pts[i].x-pts[j].x, dy=pts[i].y-pts[j].y;
    const d=Math.hypot(dx, dy);
    if (d<LINK){
      ctx.strokeStyle = `rgba(157,180,255,${1-d/LINK})`;
      ctx.beginPath(); ctx.moveTo(pts[i].x,pts[i].y); ctx.lineTo(pts[j].x,pts[j].y); ctx.stroke();
    }
  }
  requestAnimationFrame(frame);
}
frame();
The O(n²) link loop is fine to ~120 points; above that, spatial-hash into a grid and only test neighboring cells.
漂移的点在靠近时会被线条连接——经典的“网络”背景。纯Canvas 2D实现,无依赖。
js
const canvas = document.querySelector('#stars');
const ctx = canvas.getContext('2d');
let W, H, pts;
const COUNT = 80, LINK = 120;

function init(){
  W = canvas.width = innerWidth; H = canvas.height = innerHeight;
  pts = Array.from({length: COUNT}, () => ({
    x: Math.random()*W, y: Math.random()*H,
    vx: (Math.random()-0.5)*0.3, vy: (Math.random()-0.5)*0.3,
  }));
}
addEventListener('resize', init); init();

function frame(){
  ctx.clearRect(0, 0, W, H);
  for (const p of pts){
    p.x += p.vx; p.y += p.vy;
    if (p.x<0||p.x>W) p.vx*=-1;
    if (p.y<0||p.y>H) p.vy*=-1;
    ctx.fillStyle = '#9db4ff'; ctx.fillRect(p.x, p.y, 2, 2);
  }
  for (let i=0;i<COUNT;i++) for (let j=i+1;j<COUNT;j++){
    const dx=pts[i].x-pts[j].x, dy=pts[i].y-pts[j].y;
    const d=Math.hypot(dx, dy);
    if (d<LINK){
      ctx.strokeStyle = `rgba(157,180,255,${1-d/LINK})`;
      ctx.beginPath(); ctx.moveTo(pts[i].x,pts[i].y); ctx.lineTo(pts[j].x,pts[j].y); ctx.stroke();
    }
  }
  requestAnimationFrame(frame);
}
frame();
O(n²)的连接循环在粒子数约120以内表现良好;超过该数量时,可将粒子按空间哈希划分到网格中,仅测试相邻单元格内的粒子。

4. Seamless loop technique

4. 无缝循环技术

For canvas/JS, wrap time into a fixed period so motion repeats exactly. Use the loop phase (0→2π) as the argument to periodic functions:
js
const PERIOD = 12; // seconds
const phase = (t % PERIOD) / PERIOD * Math.PI * 2;
const offset = Math.sin(phase) * amp;          // returns to start at t = PERIOD
Any motion built only from
sin
/
cos
of
phase
(or integer multiples) loops seamlessly. In the shader, feed
uTime = phase
and use only
sin
/
cos
of it; for noise-scrolled gradients, scroll by an integer number of noise cells per period so the field tiles.
对于Canvas/JS,将时间包装到固定周期内,使动画完全重复。使用循环相位(0→2π)作为周期函数的参数:
js
const PERIOD = 12; // seconds
const phase = (t % PERIOD) / PERIOD * Math.PI * 2;
const offset = Math.sin(phase) * amp;          // returns to start at t = PERIOD
任何仅基于
sin
/
cos(phase)
(或其整数倍)构建的动画都能实现无缝循环。在着色器中,传入
uTime = phase
并仅使用其
sin
/
cos
值;对于噪波滚动渐变,每个周期滚动整数个噪波单元,使纹理可平铺。

5. Reduced motion + offscreen/hidden pause

5. 减少动效+视口外/隐藏时暂停

js
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
  running = false;                 // render one static frame, then stop
  renderer.render(scene, camera);
}

// Pause when the tab is hidden
document.addEventListener('visibilitychange', () => {
  running = !document.hidden && !reduce;
});

// Pause when the canvas scrolls offscreen
new IntersectionObserver(([e]) => {
  running = e.isIntersecting && !document.hidden && !reduce;
}).observe(canvas);
When
running
is false, skip the render inside the rAF loop (as in section 2) — the loop stays alive to resume cheaply, but does no GPU/CPU work.
js
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
  running = false;                 // render one static frame, then stop
  renderer.render(scene, camera);
}

// Pause when the tab is hidden
document.addEventListener('visibilitychange', () => {
  running = !document.hidden && !reduce;
});

// Pause when the canvas scrolls offscreen
new IntersectionObserver(([e]) => {
  running = e.isIntersecting && !document.hidden && !reduce;
}).observe(canvas);
running
为false时,跳过rAF循环内的渲染(如第2节所示)——循环保持运行以便快速恢复,但不占用GPU/CPU资源。

Deliver & verify (standalone HTML)

交付与验证(独立HTML)

Packaged helper (
scripts/
):
scripts/seek-shot.sh anim.html 0 1.5 3
freezes the
?t=N
harness and screenshots each moment;
scripts/contact-sheet.sh sheet.png frame-*.png
tiles them for one-glance review. See
scripts/README.md
.
A motion background ships as one
.html
file that opens directly in a browser
— markup, the background, and (for shader/canvas) Three.js or canvas JS from CDN, all inline. One file is the right tier.
Output contract:
  • One
    .html
    file: the background layer + a sample of foreground text on top to check readability/contrast.
  • One animation driver — the CSS
    @keyframes
    , the shader rAF loop, or the canvas rAF loop; just one.
  • Include the freeze harness below, matched to the technique, so any moment can be screenshotted deterministically.
Freeze harness — pin a frame for screenshots. Match the mechanism to the technique:
html
<script>
  const t = new URLSearchParams(location.search).get("t");
  if (t !== null) {
    const T = parseFloat(t);
    // CSS mesh gradient:
    document.querySelectorAll(".bg").forEach(el => {
      el.style.animationDelay = (-T) + "s";
      el.style.animationPlayState = "paused";
    });
    // GLSL shader instead? → render exactly one frame at fixed time:
    //   uniforms.uTime.value = T; renderer.render(scene, camera); running = false;
    // Canvas particles? → seed deterministically, step the sim to T, draw once, stop the loop.
  }
  window.__ready = true;                                          // ready signal for headless wait
</script>
Verify loop — render → freeze → screenshot → check:
  1. Open the file frozen at start / mid / end across one loop period:
    …/bg.html?t=0
    ,
    ?t=<period/2>
    ,
    ?t=<period>
    .
  2. Screenshot each frozen frame.
  3. Check fidelity (subtle, on-brand, seamless) and artifacts — text contrast holds at every frame, no banding in gradients/shader, the loop seam (
    t=0
    vs
    t=period
    ) matches, no GPU/console errors.
bash
npx playwright screenshot --wait-for-timeout=600 "file://$PWD/bg.html?t=12" frame-mid.png
Before you finish:
  1. Opens standalone in a browser — no console/WebGL errors, no missing CDN.
  2. One driver;
    ?t=N
    freezes the exact frame (shader renders one frame, canvas sim stepped deterministically).
  3. Screenshotted at start / mid / end — foreground text stays readable, no banding, loop seam invisible.
  4. prefers-reduced-motion
    honored (one static frame rendered, loop stopped) + pauses offscreen/hidden.
  5. devicePixelRatio
    capped at 2; motion is slow and subtle, never out-contrasting content.
打包工具
scripts/
目录):
scripts/seek-shot.sh anim.html 0 1.5 3
可冻结
?t=N
机制并截取每个时刻的截图;
scripts/contact-sheet.sh sheet.png frame-*.png
可将截图拼接成一张预览图。详见
scripts/README.md
动态背景应交付为可直接在浏览器中打开的单个
.html
文件
——包含标记、背景代码,以及(针对着色器/Canvas)来自CDN的Three.js或Canvas JS代码,全部内嵌。单个文件是最优交付形式。
输出规范:
  • 单个
    .html
    文件:包含背景层+示例前景文本,用于检查可读性/对比度。
  • 单个动画驱动——CSS
    @keyframes
    、着色器rAF循环或Canvas rAF循环;仅保留一种。
  • 包含以下冻结工具,匹配所选技术,以便确定性地截取任意时刻的截图。
冻结工具——固定帧用于截图:根据技术类型选择对应机制:
html
<script>
  const t = new URLSearchParams(location.search).get("t");
  if (t !== null) {
    const T = parseFloat(t);
    // CSS网格渐变:
    document.querySelectorAll(".bg").forEach(el => {
      el.style.animationDelay = (-T) + "s";
      el.style.animationPlayState = "paused";
    });
    // 若使用GLSL着色器?→ 在固定时间渲染一帧:
    //   uniforms.uTime.value = T; renderer.render(scene, camera); running = false;
    // 若使用Canvas粒子?→ 确定性生成种子,将模拟步进至T,绘制一次后停止循环。
  }
  window.__ready = true;                                          // 无头模式就绪信号
</script>
验证循环——渲染→冻结→截图→检查:
  1. 在一个循环周期的开始/中间/结束时刻打开冻结文件:
    …/bg.html?t=0
    ?t=<period/2>
    ?t=<period>
  2. 截取每个冻结帧的截图。
  3. 检查保真度(微妙、符合品牌风格、无缝)和瑕疵——每个帧的文本对比度都达标,渐变/着色器无条带,循环接缝(
    t=0
    vs
    t=period
    )匹配,无GPU/控制台错误。
bash
npx playwright screenshot --wait-for-timeout=600 "file://$PWD/bg.html?t=12" frame-mid.png
完成前检查:
  1. 可在浏览器中独立打开——无控制台/WebGL错误,无CDN缺失。
  2. 单个驱动;
    ?t=N
    可冻结精确帧(着色器渲染一帧,Canvas模拟确定性步进)。
  3. 在开始/中间/结束时刻截图——前景文本保持可读,无条带,循环接缝不可见。
  4. 遵循
    prefers-reduced-motion
    设置(渲染一帧静态画面,停止循环)+ 在视口外/隐藏时暂停。
  5. devicePixelRatio
    上限设为2;动画缓慢微妙,对比度永远不超过内容。

Quick reference

快速参考

NeedDo
Cheapest gradientCSS layered
radial-gradient
+
background-position
keyframes
Organic flowGLSL
fbm
noise, scroll
uTime
Network/dotscanvas particles + distance-linked lines
No seamdrive motion by
sin/cos(phase)
, identical first/last keyframe
Perf cap
setPixelRatio(min(dpr, 2))
, throttle, reduce particle count on mobile
Accessibility
prefers-reduced-motion
static fallback
Save batterypause on
visibilitychange
+
IntersectionObserver
需求实现方式
最低成本渐变CSS分层
radial-gradient
+
background-position
关键帧
有机流动效果GLSL
fbm
噪波,滚动
uTime
网络/点效果Canvas粒子+距离触发连线
无接缝循环
sin/cos(phase)
驱动动画,首尾关键帧完全相同
性能限制
setPixelRatio(min(dpr, 2))
,节流,移动端减少粒子数量
无障碍支持
prefers-reduced-motion
静态 fallback
节省电量
visibilitychange
+
IntersectionObserver
触发时暂停

Gotchas

注意事项

  • Never let the background out-contrast the foreground text — add a scrim if it does.
  • Uncapped
    devicePixelRatio
    on retina/4K murders the GPU; cap at 2.
  • background-position
    loops only if first and last keyframes match exactly.
  • Noise-scrolled shaders loop only if the scroll is an integer number of cells per period; otherwise the loop visibly jumps.
  • Forgetting the offscreen/hidden pause drains battery on mobile even when the user can't see it.
  • 绝不能让背景的对比度超过前景文本——若出现此情况,添加遮罩层。
  • 在视网膜/4K屏幕上不限制
    devicePixelRatio
    会严重消耗GPU;上限设为2。
  • 只有首尾关键帧完全相同时,
    background-position
    循环才无缝。
  • 噪波滚动着色器只有在每个周期滚动整数个单元时才会无缝循环;否则循环会出现明显跳变。
  • 忘记在视口外/隐藏时暂停动画,会在移动设备上消耗电量,即使用户看不到它。

Reference files

参考文件

  • references/background-recipes.md
    — fuller drop-in implementations: a richer multi-blob CSS mesh with blur, the complete Three.js aurora shader with mouse parallax and a true seamless-loop time wrap, a grid-optimized constellation field with mouse repulsion, a perfectly-looping noise-flow technique, and a complete reduced-motion + offscreen-pause manager wrapping all of them.
  • references/background-recipes.md
    ——更完整的可直接使用实现:包含带模糊效果的丰富多 blob CSS网格、带鼠标视差和真正无缝循环时间包装的完整Three.js极光着色器、网格优化的带鼠标排斥效果的星座场、完美循环的噪波流动技术,以及封装所有功能的完整减少动效+视口外暂停管理器。