Liquid Colour Shader WebGL Background

A full-bleed animated background painted by a single fragment shader. Bands of colour fold over each other like oil on water or light through coloured glass, and the whole pattern leans gently towards the pointer. It runs on the browser's own WebGL API, so there is no 3D library to install: one canvas, one triangle, one shader. The demo puts a perfume launch on top, with a brand line, a large product name, two buttons and a strip of scent notes on frosted panels. On phones the content stacks and the shader simply fills the taller section.

Code

// Adapted from Eldora UI (https://github.com/karthikmudunuri/eldoraui/blob/main/apps/www/registry/eldoraui/novatrix-background.tsx)
// Copyright (c) Mudunuri Bhaskara Karthikeya Varma. MIT License: https://we.inc/components/licenses
import { useEffect, useRef } from "react";
import { motion, useReducedMotion } from "framer-motion";
import { ArrowRight } from "lucide-react";

const shader = {
  tint: [1.0, 0.5, 0.44] as [number, number, number], // multiplies the colours: warm here, [1, 1, 1] for the full rainbow
  speed: 0.55,
  pointerPull: 0.18, // how far the pattern shifts towards the pointer
  resolution: 0.6, // fraction of the real pixel count that is rendered
};

const notes = [
  { label: "Top", value: "Blood orange, pink pepper" },
  { label: "Heart", value: "Saffron, warm fig" },
  { label: "Base", value: "Amber resin, cedar" },
];

const VERTEX = `
attribute vec2 position;
varying vec2 vUv;
void main() {
  vUv = position * 0.5 + 0.5;
  gl_Position = vec4(position, 0.0, 1.0);
}`;

const FRAGMENT = `
precision highp float;
uniform float uTime;
uniform vec3 uColor;
uniform vec2 uResolution;
uniform vec2 uMouse;
uniform float uAmplitude;
varying vec2 vUv;
void main() {
  float mr = min(uResolution.x, uResolution.y);
  vec2 uv = (vUv * 2.0 - 1.0) * uResolution / mr;
  uv += (uMouse - vec2(0.5)) * uAmplitude;
  float d = -uTime * 0.5;
  float a = 0.0;
  for (float i = 0.0; i < 8.0; ++i) {
    a += cos(i - d - a * uv.x);
    d += sin(uv.y * i + a);
  }
  d += uTime * 0.5;
  vec3 col = vec3(cos(uv * vec2(d, a)) * 0.6 + 0.4, cos(a + d) * 0.5 + 0.5);
  col = cos(col * cos(vec3(d, a, 2.5)) * 0.5 + 0.5) * uColor;
  gl_FragColor = vec4(col, 1.0);
}`;

/** A full-size canvas running one fragment shader with the plain WebGL API. */
function LiquidShader() {
  const ref = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    const canvas = ref.current;
    const gl = canvas?.getContext("webgl", { antialias: false, alpha: false });
    if (!canvas || !gl) return; // no WebGL: the CSS gradient behind the canvas stays visible

    const compile = (type: number, source: string) => {
      const s = gl.createShader(type)!;
      gl.shaderSource(s, source);
      gl.compileShader(s);
      return s;
    };
    const program = gl.createProgram()!;
    gl.attachShader(program, compile(gl.VERTEX_SHADER, VERTEX));
    gl.attachShader(program, compile(gl.FRAGMENT_SHADER, FRAGMENT));
    gl.linkProgram(program);
    if (!gl.getProgramParameter(program, gl.LINK_STATUS)) return;
    gl.useProgram(program);

    // One triangle that covers the whole canvas.
    gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
    const position = gl.getAttribLocation(program, "position");
    gl.enableVertexAttribArray(position);
    gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);

    const uTime = gl.getUniformLocation(program, "uTime");
    const uResolution = gl.getUniformLocation(program, "uResolution");
    const uMouse = gl.getUniformLocation(program, "uMouse");
    gl.uniform3fv(gl.getUniformLocation(program, "uColor"), shader.tint);
    gl.uniform1f(gl.getUniformLocation(program, "uAmplitude"), shader.pointerPull);

    const still = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    const mouse = { x: 0.5, y: 0.5, tx: 0.5, ty: 0.5 };
    let raf = 0;
    let visible = true;
    let time = 14; // start a little way in, where the pattern is already rich
    let before = 0;

    const draw = () => {
      gl.uniform1f(uTime, time);
      gl.uniform2f(uMouse, mouse.x, mouse.y);
      gl.drawArrays(gl.TRIANGLES, 0, 3);
    };
    const resize = () => {
      const scale = Math.min(window.devicePixelRatio || 1, 2) * shader.resolution;
      canvas.width = Math.max(1, Math.round(canvas.clientWidth * scale));
      canvas.height = Math.max(1, Math.round(canvas.clientHeight * scale));
      gl.viewport(0, 0, canvas.width, canvas.height);
      gl.uniform2f(uResolution, canvas.width, canvas.height);
      draw();
    };
    const loop = (now: number) => {
      raf = requestAnimationFrame(loop);
      const dt = Math.min((now - (before || now)) / 1000, 0.05);
      before = now;
      time += dt * shader.speed;
      mouse.x += (mouse.tx - mouse.x) * 0.05;
      mouse.y += (mouse.ty - mouse.y) * 0.05;
      draw();
    };
    const start = () => {
      cancelAnimationFrame(raf);
      before = 0;
      if (!still && visible && !document.hidden) raf = requestAnimationFrame(loop);
    };
    const onPointer = (e: PointerEvent) => {
      const r = canvas.getBoundingClientRect();
      mouse.tx = (e.clientX - r.left) / r.width;
      mouse.ty = 1 - (e.clientY - r.top) / r.height;
    };

    const sizeWatcher = new ResizeObserver(resize);
    sizeWatcher.observe(canvas);
    const viewWatcher = new IntersectionObserver(([entry]) => {
      visible = entry.isIntersecting;
      start();
    });
    viewWatcher.observe(canvas);
    document.addEventListener("visibilitychange", start);
    window.addEventListener("pointermove", onPointer, { passive: true });
    resize();
    start();

    return () => {
      cancelAnimationFrame(raf);
      sizeWatcher.disconnect();
      viewWatcher.disconnect();
      document.removeEventListener("visibilitychange", start);
      window.removeEventListener("pointermove", onPointer);
      gl.getExtension("WEBGL_lose_context")?.loseContext();
    };
  }, []);

  return <canvas ref={ref} aria-hidden="true" className="pointer-events-none absolute inset-0 h-full w-full" />;
}

export default function LiquidColourShaderWebglBackground() {
  const reduce = useReducedMotion();
  const rise = (delay: number) => ({
    initial: reduce ? false : { opacity: 0, y: 22, filter: "blur(8px)" },
    animate: { opacity: 1, y: 0, filter: "blur(0px)" },
    transition: { duration: 0.8, delay, ease: [0.22, 1, 0.36, 1] as const },
  });

  return (
    <section className="relative isolate overflow-hidden bg-gradient-to-br from-orange-500 via-rose-600 to-amber-800 px-4 py-20 text-white sm:px-6 sm:py-28">
      <LiquidShader />
      <div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/60 via-black/15 to-black/25" aria-hidden="true" />

      <div className="relative mx-auto flex min-h-[480px] max-w-5xl flex-col justify-between gap-16">
        <div className="flex items-center justify-between text-sm font-medium">
          <span className="text-lg font-semibold tracking-[0.3em]">MAISON OREL</span>
          <span className="rounded-full bg-black/35 px-3 py-1.5 backdrop-blur-sm">Eau de parfum, 50 ml</span>
        </div>

        <div>
          <motion.p {...rise(0)} className="text-sm font-semibold uppercase tracking-[0.25em] text-white/85">
            New this autumn
          </motion.p>
          <motion.h1 {...rise(0.1)} className="mt-4 max-w-3xl text-5xl font-semibold leading-[1.02] tracking-tight [text-shadow:0_2px_30px_rgba(0,0,0,0.35)] sm:text-7xl">
            Molten Hour
          </motion.h1>
          <motion.p {...rise(0.2)} className="mt-5 max-w-md text-lg leading-relaxed text-white/90 [text-shadow:0_1px_12px_rgba(0,0,0,0.45)]">
            The last warm light of the day, bottled. Bright at first, then slow, resinous and close to the skin.
          </motion.p>
          <motion.div {...rise(0.3)} className="mt-8 flex flex-wrap gap-3">
            <a href="#" className="inline-flex h-12 items-center gap-2 rounded-full bg-white px-7 text-sm font-semibold text-stone-900 transition-colors hover:bg-amber-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-rose-700">
              Buy for $140 <ArrowRight className="h-4 w-4" aria-hidden="true" />
            </a>
            <a href="#" className="inline-flex h-12 items-center rounded-full border border-white/50 bg-black/25 px-7 text-sm font-semibold text-white backdrop-blur-sm transition-colors hover:bg-black/40 focus:outline-none focus-visible:ring-2 focus-visible:ring-white">
              Order a 2 ml sample
            </a>
          </motion.div>

          <motion.dl {...rise(0.4)} className="mt-12 grid max-w-2xl gap-px overflow-hidden rounded-2xl bg-white/20 backdrop-blur-md sm:grid-cols-3">
            {notes.map((n) => (
              <div key={n.label} className="bg-black/35 px-5 py-4">
                <dt className="text-xs font-semibold uppercase tracking-widest text-white/75">{n.label}</dt>
                <dd className="mt-1 text-sm font-medium">{n.value}</dd>
              </div>
            ))}
          </motion.dl>
        </div>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/liquid-colour-shader-webgl-background.json

When to use it

How to customize it

Accessibility

Questions

What happens if WebGL is not available?

The section has an ordinary CSS gradient as its background, and the canvas is drawn on top of it. If the WebGL context cannot be created, the effect code returns early and the gradient stays visible, so the page still looks finished.

Why is there no three.js or similar?

A flat shader needs very little: compile two shaders, upload three vertices and call draw each frame. Doing that directly is about forty lines and saves shipping a rendering library for one effect.

Is it expensive to run?

The shader does eight cheap trigonometry passes per pixel. Rendering at 60% resolution with the pixel ratio capped at 2 keeps it light on laptops and phones, and the soft result suits the look.

How do I change the colours?

The simplest way is `tint`, which scales each colour channel. For a different palette entirely, edit the last two lines of the fragment shader where `col` is built, for example by mixing between two fixed colours using `col.r`.

Can I use it behind a whole page?

It is sized to its parent, so it can fill any block. For a full page, put it in a fixed wrapper behind your content and lower `resolution`, since the cost grows with the number of pixels.

Adapted from Novatrix Background in Eldora UI, used under its open-source licence. Licences and credits

Related: Gradient Mesh Background with Flowing Blobs and Grain, Aurora Background with Drifting Colour Bands, Flowing Wave Lines Hero Background.

More animated backgrounds or all free UI components. Start for free to have a full site built around it.

More in Backgrounds

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap