AtmosphereFree

Liquid Metal Shader

Liquid metallic surface in WebGL: 4-octave simplex FBM, finite-difference normals, diffuse-specular shading and iridescence, 3 palettes.

WebGLShaderMetal
Hover or click the scene to interact

This effect is free — the Atmosphere category contains 57 effects total, including 10 free. Effect.Labs has 811 vanilla effects. Explore the category →

Usage examples

① Hero / Landing — Technology brand hero

WhenAs a hero section background on a B2B or SaaS landing page signaling 'cutting-edge technology' without external images or video.
WhyThe animated silver shader replaces an HD video: 0 network requests, ~1.5 KB of JS, 100% GPU-rendered. It draws the eye without distracting from the main CTA.
Settingssettings.color = 'silver' (default), settings.speed = 0.7 for slow, premium movement, settings.lightAngle = 30 for dramatic raking light.

② Product component — Pro plan / premium pricing card

WhenAs the background of a 'Pro Plan' card or high-end product tile, visually distinguishing it from standard offers.
WhyThe gold palette turns a static golden background into a living surface and reinforces perceived value without any external asset.
Settingssettings.color = 'gold', settings.distortion = 0.5 (subtle ripples), settings.speed = 0.8.

③ Full-screen background — Luxury watch or tech brand hero

WhenAs a hero section or product page background for a brand positioned around luxury, precision, or materiality — watchmaking, high-end audio, metal credit cards, premium physical products.
WhyThe animated metallic surface embodies the brand's material: liquid metal covers 100% of the frame with no UI element shrinking its impact. The effect is the background — not decoration layered on top.
Settingssettings.color = 'gold' for a warm, precious tone, settings.speed = 0.5 (imperceptibly slow movement), settings.distortion = 0.7 (visible ripples), settings.lightAngle = 20 for raking light that sculpts every relief.

How it works

A fullscreen WebGL 1 quad drives the effect. At init, two GLSL shaders (minimal vertex + fragment) are compiled and linked; a Float32Array of 4 vertices (−1/+1) forms the triangle-strip covering the entire canvas. Uniforms u_res and u_time are updated each frame via requestAnimationFrame. The canvas is sized to the actual physical DPR (devicePixelRatio, capped at 2) via gl.viewport, and a resize listener recalibrates dynamically.

The surface is entirely generated in the fragment shader using classic 2D simplex noise (Ashima Arts, MIT license). An FBM (fractal brownian motion) function chains 4 octaves: each octave doubles the spatial frequency and halves the amplitude, accumulating a height field h = fbm(uv × 2 + vec2(t, t×0.7)). u_distortion controls global amplitude (0.6 default), u_speed the temporal scroll rate.

The surface normal vector is estimated by finite differences: two FBM samples offset by ε = 0.01 in x and y reconstruct normal = normalize(vec3(Δh/Δx, Δh/Δy, 1)). A diffuse term (normal · lightDir dot product) and a specular highlight at power 32 (Phong model) are blended with an iridescence factor — 0.5 + 0.5 × sin(h×8 + t×2) — to interpolate the three color stops of the active palette. A radial vignette (1 − 0.4 × |uv|) darkens the edges. An IntersectionObserver pauses rendering when the canvas leaves the viewport.

Accessibility

  • prefers-reduced-motion: reduceinitMetal() returns immediately; the canvas stays blank and no loop runs. The shader comment mentions a 'CSS gradient fallback via @media' but no @media (prefers-reduced-motion) rule is defined in the delivered CSS — add one as needed.
  • The canvas in the provided HTML has no aria-hidden or role attribute: add aria-hidden="true" if purely decorative, or wrap it in a role="img" aria-label="Animated metallic surface" if it carries meaning.
  • The effect is purely passive (no pointer or keyboard interaction) — no focus management required.
  • WebGL fallback: if WebGL is unavailable, the canvas is hidden (style.display = 'none') with no alternative message — overlaid text content stays visible.

Browser compatibility

Requires WebGL 1 (no optional extensions) — present in all modern browsers since 2015. Zero external dependencies.

Chrome 56+✓ Full
Firefox 51+✓ Full
Safari 15+✓ Full
Edge 79+✓ Full
Mobile iOS✓ WebGL OK
Android Chrome✓ Full

Without WebGL, the canvas is silently hidden (display:none) — no fatal JS error. Any overlaid text or UI content remains fully visible.

The code

Copy the three blocks into your page. No dependencies.

HTML
<div class="ps-canvas-wrap">
  <canvas class="ps-canvas" id="psLiquidMetal" width="393" height="280" style="width: 392.672px; height: 280px;">
  </canvas>
</div>
CSS
.ps-canvas  {
   width: 100%;
   height: 280px;
   display: block;
   border-radius: 0px;
   }

.ps-canvas-wrap  {
   position: relative;
   width: 100%;
   height: 280px;
   }

.ps-canvas-wrap canvas  {
   position: absolute;
   top: 0px;
   left: 0px;
   width: 100%;
   height: 100%;
   }
JavaScript (fx-0783)
var c=document.createElement('div');

var b=document.querySelector('.nav-theme-toggle');

const canvas = document.getElementById('psStarfield');

(function() {
  var settings = { color: 'silver', speed: 1.0, distortion: 0.6, lightAngle: 45 };
  var reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  function initMetal(canvas) {
  // Reduced motion : fallback CSS gradient déjà en place via @media
  if (reduceMotion) return;

  const gl = canvas.getContext('webgl', { antialias: false, alpha: false }) ||
             canvas.getContext('experimental-webgl');

  // WebGL not supported → CSS gradient fallback (handled in CSS @media query)
  if (!gl) {
    canvas.style.display = 'none';
    return;
  }

  // === Vertex shader : fullscreen quad ===
  const VS = `
    attribute vec2 a_pos;
    void main() { gl_Position = vec4(a_pos, 0.0, 1.0); }
  `;

  // === Fragment shader : liquid metal via Perlin noise + raymarching simplifié ===
  const FS = `
    precision highp float;
    uniform vec2  u_res;
    uniform float u_time;
    uniform float u_speed;
    uniform float u_distortion;
    uniform float u_light_angle;
    uniform vec3  u_color_a;
    uniform vec3  u_color_b;
    uniform vec3  u_color_c;

    // Classic 2D simplex noise (Ashima Arts, MIT)
    vec3 mod289(vec3 x) { return x - floor(x * (1.0/289.0)) * 289.0; }
    vec2 mod289(vec2 x) { return x - floor(x * (1.0/289.0)) * 289.0; }
    vec3 permute(vec3 x) { return mod289(((x*34.0)+1.0)*x); }

    float snoise(vec2 v){
      const vec4 C = vec4(0.211324865405187, 0.366025403784439,
                         -0.577350269189626, 0.024390243902439);
      vec2 i  = floor(v + dot(v, C.yy));
      vec2 x0 = v - i + dot(i, C.xx);
      vec2 i1 = (x0.x > x0.y) ? vec2(1.0,0.0) : vec2(0.0,1.0);
      vec4 x12 = x0.xyxy + C.xxzz;
      x12.xy -= i1;
      i = mod289(i);
      vec3 p = permute( permute( i.y + vec3(0.0, i1.y, 1.0))
                              + i.x + vec3(0.0, i1.x, 1.0));
      vec3 m = max(0.5 - vec3(dot(x0,x0), dot(x12.xy,x12.xy), dot(x12.zw,x12.zw)), 0.0);
      m = m*m; m = m*m;
      vec3 x = 2.0 * fract(p * C.www) - 1.0;
      vec3 h = abs(x) - 0.5;
      vec3 ox = floor(x + 0.5);
      vec3 a0 = x - ox;
      m *= 1.79284291400159 - 0.85373472095314 * (a0*a0 + h*h);
      vec3 g;
      g.x  = a0.x  * x0.x  + h.x  * x0.y;
      g.yz = a0.yz * x12.xz + h.yz * x12.yw;
      return 130.0 * dot(m, g);
    }

    // Octave fbm for richer surface
    float fbm(vec2 p) {
      float v = 0.0;
      float a = 0.5;
      for (int i = 0; i < 4; i++) {
        v += a * snoise(p);
        p *= 2.0;
        a *= 0.5;
      }
      return v;
    }

    void main() {
      vec2 uv = (gl_FragCoord.xy - 0.5 * u_res) / min(u_res.x, u_res.y);
      float t = u_time * u_speed * 0.3;

      // Surface height via fbm
      float h = fbm(uv * 2.0 + vec2(t, t * 0.7)) * u_distortion;

      // Surface normal via gradient (finite differences)
      float eps = 0.01;
      float hx = fbm((uv + vec2(eps,0.0)) * 2.0 + vec2(t, t*0.7)) * u_distortion;
      float hy = fbm((uv + vec2(0.0,eps)) * 2.0 + vec2(t, t*0.7)) * u_distortion;
      vec3 normal = normalize(vec3((h - hx)/eps, (h - hy)/eps, 1.0));

      // Light direction
      float angle = radians(u_light_angle);
      vec3 lightDir = normalize(vec3(cos(angle), sin(angle), 0.6));

      // Specular metal reflection
      float diffuse = max(dot(normal, lightDir), 0.0);
      vec3 viewDir = vec3(0.0, 0.0, 1.0);
      vec3 reflectDir = reflect(-lightDir, normal);
      float spec = pow(max(dot(viewDir, reflectDir), 0.0), 32.0);

      // Iridescence factor (used by gold/iridescent presets)
      float irid = 0.5 + 0.5 * sin(h * 8.0 + t * 2.0);

      // Mix three colour stops based on diffuse + iridescence
      vec3 base = mix(u_color_a, u_color_b, diffuse);
      base = mix(base, u_color_c, irid * 0.5);

      // Metal gloss : add specular highlight
      vec3 finalColor = base + vec3(spec * 1.2);

      // Vignette subtle
      float vig = 1.0 - 0.4 * length(uv);
      finalColor *= vig;

      gl_FragColor = vec4(finalColor, 1.0);
    }
  `;

  // === Compile + link program ===
  function compile(type, src) {
    const sh = gl.createShader(type);
    gl.shaderSource(sh, src);
    gl.compileShader(sh);
    if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
      console.error('Shader compile error:', gl.getShaderInfoLog(sh));
      gl.deleteShader(sh);
      return null;
    }
    return sh;
  }

  const vs = compile(gl.VERTEX_SHADER, VS);
  const fs = compile(gl.FRAGMENT_SHADER, FS);
  if (!vs || !fs) return;

  const prog = gl.createProgram();
  gl.attachShader(prog, vs);
  gl.attachShader(prog, fs);
  gl.linkProgram(prog);
  if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
    console.error('Program link error:', gl.getProgramInfoLog(prog));
    return;
  }
  gl.useProgram(prog);

  // === Fullscreen quad ===
  const buf = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, buf);
  gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 1,-1, -1,1, 1,1]), gl.STATIC_DRAW);
  const aPos = gl.getAttribLocation(prog, 'a_pos');
  gl.enableVertexAttribArray(aPos);
  gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);

  // === Uniforms ===
  const uRes = gl.getUniformLocation(prog, 'u_res');
  const uTime = gl.getUniformLocation(prog, 'u_time');
  const uSpeed = gl.getUniformLocation(prog, 'u_speed');
  const uDist = gl.getUniformLocation(prog, 'u_distortion');
  const uLight = gl.getUniformLocation(prog, 'u_light_angle');
  const uColA = gl.getUniformLocation(prog, 'u_color_a');
  const uColB = gl.getUniformLocation(prog, 'u_color_b');
  const uColC = gl.getUniformLocation(prog, 'u_color_c');

  // === Color presets ===
  const PALETTES = {
    silver:     { a: [0.10, 0.11, 0.13], b: [0.85, 0.88, 0.92], c: [0.55, 0.60, 0.70] },
    gold:       { a: [0.20, 0.13, 0.05], b: [0.95, 0.78, 0.30], c: [0.70, 0.45, 0.15] },
    iridescent: { a: [0.10, 0.05, 0.20], b: [0.30, 0.85, 0.95], c: [0.95, 0.40, 0.85] }
  };

  function syncCanvasSize() {
    const rect = canvas.parentElement.getBoundingClientRect();
    const dpr = Math.min(window.devicePixelRatio || 1, 2);
    canvas.width = Math.round(rect.width * dpr);
    canvas.height = Math.round(rect.height * dpr);
    canvas.style.width = rect.width + 'px';
    canvas.style.height = rect.height + 'px';
    gl.viewport(0, 0, canvas.width, canvas.height);
  }
  syncCanvasSize();
  window.addEventListener('resize', syncCanvasSize);

  // === Pause when offscreen (perf) ===
  let visible = true;
  if ('IntersectionObserver' in window) {
    new IntersectionObserver((entries) => {
      entries.forEach(e => visible = e.isIntersecting);
    }, { threshold: 0.01 }).observe(canvas);
  }

  // === Render loop ===
  const start = performance.now();
  function render() {
    if (visible) {
      const t = (performance.now() - start) / 1000;
      const pal = PALETTES[settings.color] || PALETTES.silver;
      gl.uniform2f(uRes, canvas.width, canvas.height);
      gl.uniform1f(uTime, t);
      gl.uniform1f(uSpeed, settings.speed);
      gl.uniform1f(uDist, settings.distortion);
      gl.uniform1f(uLight, settings.lightAngle);
      gl.uniform3fv(uColA, pal.a);
      gl.uniform3fv(uColB, pal.b);
      gl.uniform3fv(uColC, pal.c);
      gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
    }
    requestAnimationFrame(render);
  }
  render();
  } // fin initMetal

  var el = document.getElementById('psLiquidMetal');
  if (el) initMetal(el);
})();

Customize

Options passed to the API or data-* attributes:

Option / propertyDefaultEffect
settings.color 'silver' Active palette — 'silver' (metallic blue-grey), 'gold' (warm golden), 'iridescent' (violet → cyan → magenta). Edit the JS constant inside the IIFE.
settings.speed 1.0 FBM field scroll speed. 0.5 = very slow and premium; 2.0 = agitated and dynamic.
settings.distortion 0.6 Ripple amplitude. 0.2 = near-flat surface; 1.0 = heavily distorted metal.
settings.lightAngle 45 Light direction in degrees (passed to radians() in the shader). 0 = raking horizontal; 90 = zenithal; 180 = backlit.
PALETTES (JS object) 3 presets Add a custom palette: PALETTES.copper = { a:[0.25,0.10,0.03], b:[0.80,0.45,0.20], c:[0.60,0.28,0.10] }, then settings.color = 'copper'. Values are linear [0,1] per channel.
FBM octaves (shader line) 4 In the FS, the loop for (int i=0;i<4;i++) — change 4 to 2 (faster, coarser texture) or 6 (fine detail, slightly more GPU load).

FAQ

The description says 'Perlin' but the code uses 'simplex' — what's the difference?

The fragment shader implements 2D simplex noise by Ken Perlin (Ashima Arts variant, MIT license), not classic square-grid Perlin noise. Simplex uses a triangular grid: it eliminates directional artifacts along grid axes and is roughly 2× cheaper in 2D. The category card uses 'Perlin' as a common shorthand.

Does the effect work on integrated GPUs (Intel Iris, Apple M-series)?

Yes. The shader is intentionally lightweight: highp float, 4 FBM octaves, a single draw call, no GL extensions. It runs comfortably at 60 fps on integrated GPUs. The IntersectionObserver pauses rendering off-screen to avoid wasting resources.

Can <code>lightAngle</code> be animated in real time for a rotating-light effect?

settings.lightAngle is read every frame in the render loop. An external RAF that does settings.lightAngle += 0.3 produces continuous light rotation. Combined with settings.color = 'gold', it simulates a golden metal rotating under a spotlight.