← All WebGL effects

Molten Mirror

A four-number room sampled through a bent view direction, so every highlight is a reflection rather than a painted one.

ambientpremiumelegantfuturistic1 draw call · full-screen shader · light · 1 context · automatic · looping
queued1 draw call · 1 context
Variant

The scene in this preview is the file shown here. The surrounding demo shell only provides context and is not part of the copied code.

326 lines · react + three
"use client";

import * as React from "react";
import * as THREE from "three";

/**
 * Molten Mirror — metal is what it reflects.
 *
 * A polished surface has almost no colour of its own. What tells you
 * something is chrome rather than grey plastic is that it shows you the
 * room, bent: a bright band where the ceiling is, a dark one where the
 * floor is, and a hard edge between them that stretches and pinches as
 * the surface curves. Grey with a soft highlight painted on reads as
 * plastic every time, and no amount of extra gloss fixes it, because
 * the missing thing is not shininess — it is that there is nothing to
 * see in it.
 *
 * So this file builds a room before it builds a surface. The room is
 * four numbers: a dark floor, a bright ceiling band, a horizon, and how
 * sharp the transition between them is. Then the surface perturbs the
 * direction you are looking and samples the room through it. Every
 * ripple, every stretched highlight and every pinch where two ripples
 * meet is the room being bent — nothing draws a highlight anywhere.
 *
 * The environment is arithmetic rather than a cube map for the reason
 * everything in this section is: an entry that needs an HDRI to run is
 * not a file you can paste.
 *
 * Single file, react and three only. No textures, no add-ons.
 */

type Variant = "subtle" | "default" | "playful";

const VARIANTS = {
  subtle: { flowRate: 0.05, rippleCount: 3, sheenOpacity: 0.32, reliefScale: 0.85 },
  default: { flowRate: 0.12, rippleCount: 5, sheenOpacity: 0.52, reliefScale: 1.2 },
  playful: { flowRate: 0.28, rippleCount: 9, sheenOpacity: 0.74, reliefScale: 1.75 },
} as const;

const PALETTES = {
  night: {
    floor: [0.04, 0.045, 0.06],
    ceiling: [0.34, 0.37, 0.44],
    tint: [0.55, 0.60, 0.74],
    ink: 0,
    css: "linear-gradient(180deg, #2a2e38 0%, #101319 58%, #0a0c11 100%)",
  },
  day: {
    floor: [0.55, 0.57, 0.62],
    ceiling: [0.93, 0.94, 0.97],
    tint: [0.80, 0.82, 0.88],
    ink: 0,
    css: "linear-gradient(180deg, #f2f3f6 0%, #cfd2d9 58%, #b6bac3 100%)",
  },
} as const;

const VERTEX = /* glsl */ `
  varying vec2 vUv;
  void main() {
    vUv = uv;
    gl_Position = vec4(position.xy, 0.0, 1.0);
  }
`;

const FRAGMENT = /* glsl */ `
  varying vec2 vUv;

  uniform vec2  uResolution;
  uniform float uTime;
  uniform float uInk;
  uniform float  uRipples;
  uniform float  uOpacity;
  uniform float  uRelief;
  uniform vec3  uFloor;
  uniform vec3  uCeiling;
  uniform vec3  uTint;

  /**
   * The room, as a function of which way you are looking.
   *
   * Four numbers and a horizon. It is not much of a room, and it does
   * not need to be: what a mirror needs from its surroundings is a
   * strong light-to-dark edge, because that edge is the only thing in
   * the reflection whose distortion the eye can actually read.
   */
  vec3 room(vec2 dir) {
    // Most of the frame lands below this horizon on purpose. A mirror
    // whose room is mostly ceiling is a white panel, and a white panel
    // is not a backdrop anything can be written on.
    float horizon = smoothstep(-0.10, 0.55, dir.y);
    vec3 base = mix(uFloor, uCeiling, horizon);

    // One narrow strip light above the horizon. Narrow matters: a broad
    // one washes the whole surface and there is nothing left for the
    // curvature to stretch.
    float band = exp(-pow((dir.y - 0.46) * 7.5, 2.0));
    return base + uTint * band * 0.85;
  }

  /** The surface height. Ripples that meet make the pinches. */
  float relief(vec2 p, float t, float ripples) {
    return sin(p.x * ripples * 1.6 + t * 0.71) * 0.55
         + sin(p.y * ripples * 1.9 - t * 0.53) * 0.45
         + sin((p.x + p.y * 0.6) * ripples * 2.9 + t * 0.37) * 0.22
         + sin((p.x * 0.4 - p.y) * ripples * 4.3 - t * 0.19) * 0.11;
  }

  float hash(vec2 p) {
    return fract(sin(dot(p, vec2(41.3, 289.1))) * 43758.5453);
  }

  void main() {
    float aspect = uResolution.x / max(uResolution.y, 1.0);
    vec2 p = vec2(vUv.x * aspect, vUv.y) * uRelief;

    // The surface normal, from the slope of the height field. Central
    // differences rather than an analytic derivative because the sum
    // above is meant to stay easy to add another term to.
    float e = 0.0022;
    float dhx = relief(p + vec2(e, 0.0), uTime, uRipples) - relief(p - vec2(e, 0.0), uTime, uRipples);
    float dhy = relief(p + vec2(0.0, e), uTime, uRipples) - relief(p - vec2(0.0, e), uTime, uRipples);
    vec2 slope = vec2(dhx, dhy) / (2.0 * e);

    // Look straight in, then bend by the slope. That bend is the whole
    // effect: the room arrives distorted, and distortion is what metal
    // looks like.
    vec2 dir = normalize(vec2(vUv.x - 0.5, vUv.y - 0.35) * 0.9 - slope * 0.09 + 1e-6);

    vec3 reflected = room(dir);

    // Grazing angles reflect more, which is why the far edge of a metal
    // panel is always the bright one.
    float grazing = pow(1.0 - clamp(abs(dir.y), 0.0, 1.0), 3.0);
    vec3 color = mix(uFloor, reflected, clamp(uOpacity * (0.45 + 0.55 * grazing) * 1.15, 0.0, 1.0));

    color += (hash(gl_FragCoord.xy) - 0.5) / 255.0;
    gl_FragColor = vec4(clamp(color, 0.0, 1.0), 1.0);
  }
`;

/** Relative luminance of a computed rgb() colour, 0–1. */
function luminanceOf(color: string): number {
  const parts = color.match(/[\d.]+/g);
  if (!parts || parts.length < 3) return 0.9;
  const [r, g, b] = parts.slice(0, 3).map((value) => Number(value) / 255);
  return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}

export type MoltenMirrorProps = {
  variant?: Variant;
  /** Any CSS height. The effect fills whatever box it is given. */
  height?: string;
  className?: string;
  style?: React.CSSProperties;
  children?: React.ReactNode;
};

export function MoltenMirror({
  variant = "default",
  height = "100%",
  className,
  style,
  children,
}: MoltenMirrorProps) {
  const hostRef = React.useRef<HTMLDivElement | null>(null);
  const cfg = VARIANTS[variant] ?? VARIANTS.default;

  React.useEffect(() => {
    const host = hostRef.current;
    if (!host) return;

    // A full-bleed effect paints every pixel, so it cannot mix its
    // neutrals from the host the way a component can. It reads the
    // inherited colour only to decide which way round the page is.
    const palette =
      luminanceOf(getComputedStyle(host).color) > 0.5 ? PALETTES.night : PALETTES.day;
    host.style.background = palette.css;

    const reduced =
      typeof window.matchMedia === "function" &&
      window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    let renderer: THREE.WebGLRenderer;
    try {
      renderer = new THREE.WebGLRenderer({
        antialias: false,
        alpha: false,
        powerPreference: "low-power",
      });
    } catch {
      // No WebGL. The CSS still above is already painted and stands in
      // for the effect, so there is nothing further to do.
      return;
    }

    const canvas = renderer.domElement;
    canvas.style.cssText =
      "position:absolute;inset:0;width:100%;height:100%;display:block;pointer-events:none;";
    renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
    // First child, not last: an absolutely positioned canvas appended
    // after the content would paint over it.
    host.insertBefore(canvas, host.firstChild);

    const uniforms = {
      uResolution: { value: new THREE.Vector2(1, 1) },
      uTime: { value: 0 },
      uInk: { value: palette.ink },
      uRipples: { value: 5 },
      uOpacity: { value: 0.52 },
      uRelief: { value: 1.2 },
      uFloor: { value: new THREE.Vector3(...palette.floor) },
      uCeiling: { value: new THREE.Vector3(...palette.ceiling) },
      uTint: { value: new THREE.Vector3(...palette.tint) },
    };
    uniforms.uRipples.value = cfg.rippleCount;
    uniforms.uOpacity.value = cfg.sheenOpacity;
    uniforms.uRelief.value = cfg.reliefScale;

    const geometry = new THREE.PlaneGeometry(2, 2);
    const material = new THREE.ShaderMaterial({
      vertexShader: VERTEX,
      fragmentShader: FRAGMENT,
      uniforms,
      depthTest: false,
      depthWrite: false,
    });
    const scene = new THREE.Scene();
    scene.add(new THREE.Mesh(geometry, material));
    const camera = new THREE.Camera();

    const resize = () => {
      const width = Math.max(1, host.clientWidth);
      const heightPx = Math.max(1, host.clientHeight);
      renderer.setSize(width, heightPx, false);
      uniforms.uResolution.value.set(width, heightPx);
    };
    resize();

    const observer = new ResizeObserver(resize);
    observer.observe(host);

    let frame = 0;
    let phase = 0;
    let last = 0;
    let running = false;

    const draw = () => renderer.render(scene, camera);

    const tick = (now: number) => {
      const delta = last ? Math.min((now - last) / 1000, 0.05) : 0;
      last = now;
      phase += delta * cfg.flowRate * 6.0;
      uniforms.uTime.value = phase;
      draw();
      frame = requestAnimationFrame(tick);
    };

    const start = () => {
      if (running) return;
      running = true;
      last = 0;
      frame = requestAnimationFrame(tick);
    };
    const stop = () => {
      running = false;
      cancelAnimationFrame(frame);
    };

    // A lost context is routine, not exceptional: a browser grants a
    // limited number and drops the oldest when a page asks for more.
    // preventDefault is what makes the restore possible at all.
    const onLost = (event: Event) => {
      event.preventDefault();
      stop();
    };
    const onRestored = () => {
      resize();
      start();
    };
    canvas.addEventListener("webglcontextlost", onLost);
    canvas.addEventListener("webglcontextrestored", onRestored);

    if (reduced) {
      // A still mirror still reflects. The room and its bend are
      // properties of the surface, not of the animation.
      uniforms.uTime.value = 1.3;
      draw();
    } else {
      start();
    }

    return () => {
      stop();
      observer.disconnect();
      canvas.removeEventListener("webglcontextlost", onLost);
      canvas.removeEventListener("webglcontextrestored", onRestored);
      geometry.dispose();
      material.dispose();
      renderer.dispose();
      // dispose() releases three's own objects; the context itself is
      // only handed back here, and a grid of these depends on it being
      // handed back promptly.
      renderer.forceContextLoss();
      canvas.remove();
    };
  }, [cfg]);

  return (
    <div
      ref={hostRef}
      className={className}
      style={{
        position: "relative",
        width: "100%",
        height,
        overflow: "hidden",
        background: PALETTES.night.css,
        ...style,
      }}
    >
      {children}
    </div>
  );
}

export default MoltenMirror;

About this effect

A backdrop for a surface that should feel like it is worth something — a balance, a card, a premium tier. A polished material has almost no colour of its own, and what tells you something is chrome rather than grey plastic is that it shows you the room, bent: a bright band where the ceiling is, a dark one where the floor is, and a hard edge between them that stretches and pinches as the surface curves. Grey with a soft highlight painted on top reads as plastic every time, and adding gloss never fixes it, because what is missing is not shininess but the fact that there is nothing to see in it. So this file builds a room before it builds a surface — a floor, a ceiling, a horizon and one strip light, four numbers in total — and then perturbs the view direction by the slope of a height field and samples the room through it. That is not much of a room and it does not need to be: what a mirror needs from its surroundings is one strong light-to-dark edge, because that edge is the only thing in a reflection whose distortion the eye can read. Every stretched highlight and every pinch where two ripples meet is that edge being bent. The environment is arithmetic rather than a cube map for the reason everything in this section is: an entry that needs an HDRI to run is not a file anyone can paste.

Balance panelPremium tier cardProduct heroCheckout surface

Related effects