A GPU-accelerated, animated wave background inspired by Sony's XrossMediaBar (XMB) interface — the iconic home screen of the PlayStation 3 and PSP. Built with raw WebGL and GLSL shaders. Zero runtime dependencies.
From GitHub:
npm install github:bandlayash/xmbScript tag (CDN):
<script src="https://cdn.jsdelivr.net/gh/bandlayash/xmb/dist/index.global.js"></script>Or just copy dist/index.global.js into your project — no package manager needed.
import { XMBWaves } from 'xmb-waves';
new XMBWaves({ target: '#my-background' });That's it. Creates a fullscreen canvas with 9 animated teal waves at 45% opacity.
With options:
const waves = new XMBWaves({
target: '#hero',
color: '#9933ff',
waveCount: 5,
opacity: 0.3,
speed: 1.5,
blur: 2,
});
// Update at runtime
waves.color = '#ff3399';
waves.speed = 0.5;
// Cleanup (e.g. on SPA navigation)
waves.destroy();Script tag usage:
<div id="background" style="position: fixed; inset: 0; z-index: 0;"></div>
<script src="dist/index.global.js"></script>
<script>
const { XMBWaves: XMB } = XMBWaves;
new XMB({ target: '#background' });
</script>| Option | Type | Default | Description |
|---|---|---|---|
target |
HTMLCanvasElement | HTMLElement | string |
required | A canvas (used directly), any element (canvas created inside), or CSS selector |
color |
string | [number, number, number] |
"#00d1d1" |
Wave color as hex or normalized RGB tuple |
waves |
WaveDefinition[] |
Built-in 12-wave preset | Custom wave definitions |
waveCount |
number |
9 |
Number of waves to render (from the waves array) |
opacity |
number |
0.45 |
Canvas opacity (0-1) |
speed |
number |
1.0 |
Speed multiplier |
blur |
number |
1 |
CSS blur in pixels |
autoResize |
boolean |
true |
Auto-resize with container |
pixelRatio |
number |
1 |
Pixel density multiplier |
All options except target and autoResize are available as runtime properties:
waves.color = '#ff0000'; // cheap: updates uniform
waves.speed = 2.0; // cheap: updates uniform
waves.opacity = 0.8; // cheap: updates canvas CSS
waves.blur = 3; // cheap: updates canvas CSS
waves.waveCount = 12; // expensive: recompiles shader
waves.waves = customWaves; // expensive: recompiles shader| Method | Description |
|---|---|
pause() |
Stop the render loop (GL context stays alive) |
resume() |
Restart after pause |
destroy() |
Full cleanup: stops rendering, removes observers, deletes GL resources, removes auto-created canvas |
resize() |
Force a resize (called automatically if autoResize is true) |
| Property | Type | Description |
|---|---|---|
canvas |
HTMLCanvasElement |
The canvas element |
gl |
WebGLRenderingContext |
The WebGL context |
isDestroyed |
boolean |
Whether destroy() has been called |
isPaused |
boolean |
Whether pause() is active |
interface WaveDefinition {
speed: number; // Animation speed
freq: number; // Wave frequency
amp: number; // Wave amplitude
phase: number; // Phase shift
vOff: number; // Vertical offset (0 = bottom, 1 = top)
lw: number; // Line width
sharp: number; // Sharpness (higher = thinner, brighter)
invert: boolean; // Flip the falloff direction
}The built-in 12-wave preset is exported for extending:
import { XMBWaves, DEFAULT_WAVES } from 'xmb-waves';
const customWaves = [
...DEFAULT_WAVES,
{ speed: 0.3, freq: 0.45, amp: 0.10, phase: 0, vOff: 0.55, lw: 0.08, sharp: 18, invert: false },
];
new XMBWaves({ target: 'body', waves: customWaves, waveCount: 13 });Open demo/index.html to see the interactive demo with a live control panel for color, wave count, opacity, speed, and blur.
The PlayStation XMB background is one of the most recognizable ambient UI elements in consumer electronics. Its slowly undulating ribbons of light create a sense of calm, depth, and futurism without demanding attention. The design principles at work:
- Ambient, not decorative — The waves exist in the background. They don't compete with content; they provide atmosphere.
- Luminous ribbons — Each wave isn't a hard line but a soft, glowing ribbon. The brightness fades smoothly from the wave's center, creating a volumetric "lit from within" appearance.
- Layered depth — Multiple waves at different speeds, sizes, and positions create parallax-like depth without any 3D engine.
- Additive composition — Where waves overlap, their light adds together, creating brighter intersections that shimmer organically.
The wave effect requires evaluating a mathematical function at every pixel on every frame. For a 1920x1080 display at 60fps, that's ~124 million pixel evaluations per second. Canvas 2D would choke — it runs on the CPU and would need to iterate through each pixel in JavaScript.
WebGL compiles the wave math into a GLSL fragment shader that runs on the GPU, evaluating all pixels in parallel. The result: buttery 60fps on any modern device, with near-zero CPU usage.
The entire effect consists of three parts:
┌──────────────────────────────────────────────────────────────┐
│ Vertex Shader │
│ Draws a fullscreen quad (two triangles covering the screen) │
│ Passes coordinates to fragment shader │
└───────────────────────┬──────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────┐
│ Fragment Shader │
│ For each pixel: │
│ 1. Compute 9 sine waves via calcSine() │
│ 2. Accumulate their color contributions (additive) │
│ 3. Push bright areas toward white (luminosity glow) │
│ 4. Output final RGBA │
└───────────────────────┬──────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────┐
│ Render Loop (JavaScript) │
│ requestAnimationFrame → update time uniform → draw │
│ Also handles: resize, color/opacity changes │
└──────────────────────────────────────────────────────────────┘
The vertex shader receives 4 vertices forming a rectangle that covers clip space from (-1, -1) to (1, 1):
attribute vec2 aVertexPosition;
void main() {
gl_Position = vec4(aVertexPosition, 0.0, 1.0);
}This is a standard WebGL pattern for full-screen post-processing effects. The real work happens in the fragment shader.
Every wave is generated by the same function. It takes a pixel position and wave parameters, and returns how much light that wave contributes at that pixel.
vec3 calcSine(
vec2 uv, // normalized pixel coordinate (0–1)
float speed, // how fast the wave moves
float frequency, // how many oscillations across the screen
float amplitude, // height of the wave peaks
float phaseShift, // horizontal offset
float verticalOffset,// vertical center position
vec3 baseColor, // wave color (RGB)
float lineWidth, // thickness of the visible ribbon
float sharpness, // how quickly brightness falls off
bool invertFalloff // glow direction: above or below the wave
)Where
float angle = uTime * speed * frequency * -1.0 + (phaseShift + uv.x) * 2.0;
float waveY = sin(angle) * amplitude + verticalOffset;This is a traveling sine wave. Breaking down the angle calculation:
| Term | Purpose |
|---|---|
uTime * speed * frequency * -1.0 |
Drives the wave leftward over time. Higher speed or frequency = faster movement. The -1.0 reverses direction. |
(phaseShift + uv.x) * 2.0 |
Maps the horizontal screen position to the wave's phase. The * 2.0 means ~1 full cycle is visible across the screen width (before frequency scaling). |
The result
In plain English: As time advances, the sine function's input changes, causing the wave to slide horizontally. Each pixel's x-coordinate maps to a different point along the sine curve, creating the classic undulating shape.
float deltaY = waveY - uv.y;
float distanceVal = distance(waveY, uv.y);For each pixel, we compute the signed delta
The distance is asymmetrically scaled based on which side of the wave the pixel falls:
if (invertFalloff) {
if (deltaY > 0.0) distanceVal *= 4.0;
} else {
if (deltaY < 0.0) distanceVal *= 4.0;
}This is what gives the waves their one-sided glow. Instead of glowing equally above and below, each wave is bright on one side and fades rapidly on the other (distance is multiplied by 4, making it fall off 4x faster).
invertFalloff = false→ Wave glows upward (bright above the line, dim below)invertFalloff = true→ Wave glows downward (bright below the line, dim above)
This mimics light refracting through a curved surface — one side catches the light, the other is in shadow.
First, a Hermite interpolation maps distance to a
Then sharpness concentrates the brightness near the wave center:
Where
float smoothVal = smoothstep(lineWidth * waveWidthFactor, 0.0, distanceVal);
float scaleVal = pow(smoothVal, sharpness);Two functions shape the brightness:
smoothstep(edge, 0.0, distance) — A built-in GLSL function that returns:
1.0whendistance = 0(right on the wave line)0.0whendistance >= edge(far from the wave)- A smooth Hermite interpolation in between (no harsh cutoff)
The edge is lineWidth * 1.5 (the waveWidthFactor constant), controlling how far the glow extends.
pow(smoothVal, sharpness) — Raises the smooth value to a high power (
sharpness = 1 → linear falloff (broad, diffuse glow)
sharpness = 15 → sharp peak (tight, concentrated ribbon)
sharpness = 23 → very sharp peak (thin, laser-like line)
The relationship is exponential. Here's how
S: 0.0 0.25 0.5 0.75 1.0
n=1: 0.0 0.25 0.5 0.75 1.0 ← linear
n=15: 0.0 0.00 0.00 0.01 1.0 ← concentrated
n=23: 0.0 0.00 0.00 0.00 1.0 ← razor sharp
return min(baseColor * scaleVal, baseColor);Multiplies the base color by the brightness scalar
All wave contributions are summed additively:
vec3 acc = vec3(0.0);
acc += calcSine(uv, ...); // wave 1
acc += calcSine(uv, ...); // wave 2
// ... up to N wavesAdditive blending means where waves overlap, their brightness stacks — creating those characteristic bright nodes at intersections.
The brightest channel is extracted and used to desaturate the color:
float maxCh = max(acc.r, max(acc.g, acc.b));
vec3 outColor = mix(acc, vec3(maxCh), 0.65);This is the secret sauce for the luminous glow effect. It takes the accumulated color and shifts it 65% toward pure white (well, toward the brightest channel as a grey value). The result:
- Dim areas stay colored (teal, purple, whatever you set)
- Bright areas wash out toward white
- This mimics how real light sources appear — the center is white-hot, with color visible only at the fringes
Without this step, the waves would look flat and evenly colored. With it, they appear to emit light.
Pixels where no wave contributes any light (
if (maxCh <= 0.0) discard;The GPU skips writing these pixels to the framebuffer. Since most of the screen is empty space between waves, this significantly reduces memory bandwidth usage.
The default configuration uses 9 waves organized in three groups:
| # | Speed | Frequency | Amplitude | Phase | Line Width | Sharpness | Falloff |
|---|---|---|---|---|---|---|---|
| 1 | 0.20 | 0.20 | 0.20 | 0.0 | 0.10 | 15.0 | Up |
| 2 | 0.40 | 0.40 | 0.15 | 0.0 | 0.10 | 17.0 | Up |
| 3 | 0.30 | 0.60 | 0.15 | 0.0 | 0.05 | 23.0 | Up |
These form the main central ribbon — a slow, wide wave layered with faster, thinner harmonics.
| # | Speed | Frequency | Amplitude | Phase | Line Width | Sharpness | Falloff |
|---|---|---|---|---|---|---|---|
| 4 | 0.10 | 0.26 | 0.07 | 0.0 | 0.10 | 17.0 | Down |
| 5 | 0.30 | 0.36 | 0.07 | 0.0 | 0.10 | 17.0 | Down |
| 6 | 0.50 | 0.46 | 0.07 | 0.0 | 0.05 | 23.0 | Down |
| 7 | 0.20 | 0.58 | 0.05 | 0.0 | 0.20 | 15.0 | Down |
A subtler, lower set of waves with inverted falloff, creating the illusion of depth beneath the main ribbon.
| # | Speed | Frequency | Amplitude | Phase | V.Offset | Line Width | Sharpness | Falloff |
|---|---|---|---|---|---|---|---|---|
| 8 | 0.15 | 0.30 | 0.12 | 0.5 | 0.15 | 0.10 | 15.0 | Up |
| 9 | 0.25 | 0.22 | 0.15 | 0.3 | 0.75 | 0.10 | 17.0 | Down |
These extend the visual into the top and bottom of the viewport so the background doesn't feel empty at the edges.
The wave color is defined as an RGB triplet, normalized to 0.0–1.0:
let waveColor = [0.0, 0.82, 0.82]; // Teal (default)Common color presets:
| Color | R | G | B | Hex |
|---|---|---|---|---|
| Teal (default) | 0.00 | 0.82 | 0.82 | #00d1d1 |
| Neon Green | 0.00 | 1.00 | 0.40 | #00ff66 |
| Electric Purple | 0.60 | 0.20 | 1.00 | #9933ff |
| Warm Orange | 1.00 | 0.45 | 0.00 | #ff7300 |
| Ice Blue | 0.30 | 0.60 | 1.00 | #4d99ff |
| Hot Pink | 1.00 | 0.20 | 0.60 | #ff3399 |
| Gold | 1.00 | 0.84 | 0.00 | #ffd700 |
In the demo, use the color picker in the control panel. In code, modify the waveColor array or the WAVE_COLOR constant.
Waves are defined in the ALL_WAVES array. The waveCount variable controls how many are active:
let waveCount = 9; // Default: all 9 wavesEffect of wave count:
| Count | Visual Effect |
|---|---|
| 1–2 | Minimal, single flowing ribbon |
| 3–4 | Clear layered ribbons with some depth |
| 5–7 | Rich, complex pattern with visible layering |
| 8–9 | Full XMB effect with edge-to-edge coverage |
| 10–12 | Dense, saturated (extra waves available in the demo) |
To add entirely new waves, push to the ALL_WAVES array:
ALL_WAVES.push({
speed: 0.3, // Movement speed
freq: 0.45, // Oscillation frequency
amp: 0.10, // Wave height
phase: 0.0, // Horizontal offset
vOff: 0.55, // Vertical center (0 = bottom, 1 = top)
lw: 0.08, // Line width (glow radius)
sharp: 18.0, // Brightness concentration
invert: false // Glow direction: false = up, true = down
});Then increase waveCount (or the slider max) accordingly.
These three parameters control the character of each wave:
Speed (0.1 – 0.5 typical)
Controls how fast the wave moves horizontally. Slow waves feel calm and ambient; fast waves feel energetic. Mixing speeds creates parallax depth.
Frequency (0.2 – 0.6 typical)
Controls how many oscillations fit across the screen. Low frequency = one broad curve. High frequency = several tight oscillations. The visual frequency is
Amplitude (0.05 – 0.20 typical)
Controls the vertical range of the wave's oscillation. Large amplitude = dramatic swooping motion. Small amplitude = subtle ripples. Be careful with values above 0.25 — the wave can extend off-screen.
These control the thickness and softness of each ribbon:
Line Width (0.05 – 0.20 typical)
The radius of the glow around the wave center. Multiplied by waveWidthFactor (1.5) to get the actual extent. Wider = more diffuse glow.
Sharpness (15.0 – 23.0 typical)
The exponent
-
$n = 10$ → Soft, atmospheric glow -
$n = 15$ → Balanced ribbon (default for main waves) -
$n \geq 20$ → Thin, crisp line
These are applied to the canvas element, not the shader:
canvas.style.opacity = 0.45; // 0.0 (invisible) – 1.0 (full)
canvas.style.filter = 'blur(1px)'; // 0px (sharp) – 8px (dreamy)- Opacity controls how strongly the waves show against your background. For subtle backgrounds, use
0.2–0.4. For hero sections, try0.5–0.7. - Blur adds a soft-focus effect. The default
1pxsmooths out aliasing. Higher values create a dreamy, out-of-focus look.
This boolean controls which side of the wave glows:
invertFalloff: false invertFalloff: true
░░░░ ─────
░░░░░ ░░░░░
░░░░░░░ ░░░░░░░
───────── ░░░░░░░░░░
false— Glow extends above the wave line (like light shining up from below)true— Glow extends below the wave line (like light shining down from above)
The default configuration uses false for upper waves and true for lower waves, creating the impression of light concentrated in the middle band.
vec3 outColor = mix(acc, vec3(maxCh), 0.65);The
-
$\alpha = 0$ → Pure color, no white glow (flat, neon look) -
$\alpha = 0.65$ → Default (natural luminous glow) -
$\alpha = 1$ → Full white-out on bright areas (overexposed look)
To change this, edit the 0.65 value in the fragment shader string inside buildFragmentShader().
import { useRef, useEffect } from 'react';
import { XMBWaves } from 'xmb-waves';
export function WaveBackground({ color = '#00d1d1', opacity = 0.45 }) {
const containerRef = useRef(null);
useEffect(() => {
const waves = new XMBWaves({
target: containerRef.current,
color,
opacity,
});
return () => waves.destroy();
}, []);
return (
<div
ref={containerRef}
style={{ position: 'fixed', inset: 0, zIndex: 0, pointerEvents: 'none' }}
/>
);
}<template>
<div ref="container" class="wave-bg" />
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue';
import { XMBWaves } from 'xmb-waves';
const container = ref(null);
let waves;
onMounted(() => {
waves = new XMBWaves({ target: container.value, color: '#00d1d1' });
});
onBeforeUnmount(() => waves?.destroy());
</script>
<style scoped>
.wave-bg { position: fixed; inset: 0; z-index: 0; pointer-events: none; }
</style>To use waves behind specific content rather than fullscreen:
<div id="hero" style="position: relative; overflow: hidden; height: 100vh;">
<div style="position: relative; z-index: 1;">
<h1>Your content here</h1>
</div>
</div>
<script type="module">
import { XMBWaves } from 'xmb-waves';
new XMBWaves({ target: '#hero', opacity: 0.3 });
</script>- GPU load: Minimal. The shader is mathematically simple — 9 sine evaluations per pixel with no texture sampling or branching (aside from the discard).
- CPU load: Near zero. JavaScript only updates 4 uniform values per frame.
- Memory: One fullscreen framebuffer + a 32-byte vertex buffer. No textures.
- Mobile: Works on all modern mobile GPUs. Consider reducing wave count to 4–5 on low-end devices or using
requestAnimationFramethrottling. - Battery: For background tabs, the browser automatically pauses
requestAnimationFrame. No extra handling needed.
Works in all browsers that support WebGL 1.0:
- Chrome 9+
- Firefox 4+
- Safari 8+
- Edge 12+
- iOS Safari 8+
- Android Chrome 30+
MIT - See LICENSE
Built by Yash Bandla and Claude Code