Liquid Glass Sphere

Apple & Figma-style liquid glass sphere with GLSL refraction, animated color blobs, and organic noise distortion. Pure WebGL — no Three.js required.

Preview

Loading preview…

Customize

Size240
Speed1
IOR1.45
Shape
"use client";

import { useRef, useEffect, useCallback } from "react";
import styles from "./LiquidGlassSphere.module.css";

type Shape = "circle" | "square";

interface LiquidGlassSphereProps {
  size?: number;     // px, default 240
  speed?: number;    // time multiplier, default 1.0
  ior?: number;      // index of refraction, default 1.45
  shape?: Shape;     // "circle" or "square", default "circle"
  className?: string;
}

// Vertex shader: full-screen quad
const VERT = `
attribute vec2 position;
void main() { gl_Position = vec4(position, 0.0, 1.0); }
`;

// Fragment shader: see shader.glsl tab for full source
const FRAG = `/* ... */`;

export default function LiquidGlassSphere({
  size = 240, speed = 1, ior = 1.45, shape = "circle", className,
}: LiquidGlassSphereProps) {
  const sphereRef = useRef<HTMLDivElement>(null);
  const containerRef = useRef<HTMLDivElement>(null);
  const glRef = useRef<{ /* WebGL context + uniforms */ } | null>(null);
  const speedRef = useRef(speed);
  const iorRef = useRef(ior);
  const shapeRef = useRef(shape);

  // Update refs without re-initializing WebGL
  useEffect(() => { speedRef.current = speed; }, [speed]);
  useEffect(() => { iorRef.current = ior; }, [ior]);
  useEffect(() => { shapeRef.current = shape; }, [shape]);

  const setup = useCallback(() => {
    // Create canvas, compile shaders, link program
    // Store uniform locations: uResolution, uTime, uIOR, uShape
  }, []);

  useEffect(() => {
    setup();
    const ctx = glRef.current;
    if (!ctx) return;

    const t0 = performance.now();

    // 30fps render loop
    const loop = (now: number) => {
      ctx.gl.uniform1f(ctx.uTime,
        ((now - t0) / 1000) * speedRef.current);
      ctx.gl.uniform1f(ctx.uIOR, iorRef.current);
      ctx.gl.uniform1f(ctx.uShape,
        shapeRef.current === "square" ? 1.0 : 0.0);
      ctx.gl.drawArrays(ctx.gl.TRIANGLE_STRIP, 0, 4);
      requestAnimationFrame(loop);
    };

    // IntersectionObserver to pause when off-screen
    // prefers-reduced-motion: render single frame
    // Cleanup: loseContext, cancelAnimationFrame

    return () => { /* cleanup */ };
  }, [setup]);

  const borderRadius = shape === "square" ? size * 0.12 : "50%";

  return (
    <div ref={sphereRef} className={styles.sphere}
         style={{ width: size, height: size, borderRadius }}>
      <div ref={containerRef} className={styles.canvas}
           style={{ borderRadius }} />
    </div>
  );
}