Skip to content

Cloud Shader

Soft procedural clouds drifting across a sky gradient.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/cloud-shader.json

Manual

  1. Install the dependencies

    npm install clsx tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts and the motion tokens below. See Installation if you have not set them up yet.

    lib/color.ts
    export type Vec3 = [r: number, g: number, b: number];
    
    /**
     * Parses #rgb, #rrggbb or #rrggbbaa (alpha is ignored) into 0–255 channels.
     * Anything else falls back to black, so a typo never crashes a shader.
     */
    export function hexToRgb(hex: string): Vec3 {
      let value = hex.trim().replace(/^#/, "");
      if (value.length === 3 || value.length === 4) value = [...value.slice(0, 3)].map((c) => c + c).join("");
      if (!/^[\da-f]{6}([\da-f]{2})?$/i.test(value)) return [0, 0, 0];
      const n = Number.parseInt(value.slice(0, 6), 16);
      return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
    }
    
    /** Hex color as a normalized vec3 (each channel 0–1) for shader uniforms. */
    export function hexToVec3(hex: string): Vec3 {
      const [r, g, b] = hexToRgb(hex);
      return [r / 255, g / 255, b / 255];
    }
    
    /**
     * Any CSS color the browser understands (hex, rgb(), oklch(), color-mix() or a variable such as
     * `var(--primary)`) as 0–255 channels, computed in the context of `element` so theme variables
     * resolve. Hex takes a fast path; everything else is computed once per call, so cache the result
     * instead of calling this every frame. Browser only; unreadable values fall back to black.
     */
    export function cssColorToRgb(color: string, element: Element): Vec3 {
      const value = color.trim();
      if (/^#[\da-f]{3,8}$/i.test(value)) return hexToRgb(value);
      // Children of a canvas still get computed styles, but a regular parent is the safer host.
      const host = element instanceof HTMLCanvasElement ? (element.parentElement ?? document.body) : element;
      const probe = document.createElement("span");
      probe.style.cssText = "position:absolute;width:0;height:0;overflow:hidden;visibility:hidden";
      probe.style.color = value;
      host.appendChild(probe);
      const computed = getComputedStyle(probe).color;
      probe.remove();
      // A 1×1 canvas turns any computed color space (oklch, color(srgb …)) into sRGB bytes.
      const ctx = document.createElement("canvas").getContext("2d", { willReadFrequently: true });
      if (!ctx) return [0, 0, 0];
      ctx.fillStyle = "#000";
      ctx.fillStyle = computed;
      ctx.fillRect(0, 0, 1, 1);
      const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
      return [r, g, b];
    }
    
    /**
     * Calls `onChange` when the page theme may have changed: a class or data-theme change on <html> or
     * <body> (shadcn's `.dark`, next-themes, a data-theme switch) or a change of the OS color scheme.
     * Resolved colors are cached, so re-resolve them here. Browser only; returns the unsubscribe.
     */
    export function observeTheme(onChange: () => void): () => void {
      const observer = new MutationObserver(onChange);
      const options = { attributes: true, attributeFilter: ["class", "data-theme"] };
      observer.observe(document.documentElement, options);
      if (document.body) observer.observe(document.body, options);
      const scheme = window.matchMedia("(prefers-color-scheme: dark)");
      scheme.addEventListener("change", onChange);
      return () => {
        observer.disconnect();
        scheme.removeEventListener("change", onChange);
      };
    }
    
    /** `cssColorToRgb` as a normalized vec3 (each channel 0–1) for shader uniforms. */
    export function cssColorToVec3(color: string, element: Element): Vec3 {
      const [r, g, b] = cssColorToRgb(color, element);
      return [r / 255, g / 255, b / 255];
    }
    
    const toLinear = (c: number) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
    const toGamma = (c: number) => (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055);
    
    /** 0–255 sRGB to OKLCH: lightness 0–1, chroma, hue in radians. */
    export function rgbToOklch([r, g, b]: Vec3): Vec3 {
      const [lr, lg, lb] = [r, g, b].map((v) => toLinear(v / 255));
      const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);
      const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);
      const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb);
      const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;
      const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
      const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
      return [L, Math.hypot(A, B), Math.atan2(B, A)];
    }
    
    /** OKLCH back to 0–255 sRGB, clamped into gamut. */
    export function oklchToRgb([L, C, h]: Vec3): Vec3 {
      const A = C * Math.cos(h);
      const B = C * Math.sin(h);
      const l = (L + 0.3963377774 * A + 0.2158037573 * B) ** 3;
      const m = (L - 0.1055613458 * A - 0.0638541728 * B) ** 3;
      const s = (L - 0.0894841775 * A - 1.291485548 * B) ** 3;
      const rgb = [4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s];
      return rgb.map((v) => Math.round(Math.min(1, Math.max(0, toGamma(Math.min(1, Math.max(0, v))))) * 255)) as Vec3;
    }
    
    /**
     * Mixes two 0–255 colors in OKLCH, taking the short way around the hue circle, so midpoints keep
     * their chroma instead of turning muddy the way an RGB mix does.
     */
    export function mixOklch(a: Vec3, b: Vec3, t: number): Vec3 {
      const [L1, C1, h1] = rgbToOklch(a);
      const [L2, C2, h2] = rgbToOklch(b);
      // A near-gray end has no meaningful hue: borrow the other end's.
      const ha = C1 < 0.02 ? h2 : h1;
      const hb = C2 < 0.02 ? h1 : h2;
      let dh = hb - ha;
      if (dh > Math.PI) dh -= Math.PI * 2;
      if (dh < -Math.PI) dh += Math.PI * 2;
      return oklchToRgb([L1 + (L2 - L1) * t, C1 + (C2 - C1) * t, ha + dh * t]);
    }
    lib/webgl/noise.glsl.ts
    /**
     * Shared GLSL ES 3.00 snippets. Concatenate the ones a shader needs before its main():
     *
     *   const fragment = `${GLSL_HASH}${GLSL_SIMPLEX}${GLSL_FBM} void main() { … }`;
     *
     * GLSL_SIMPLEX needs GLSL_HASH, and GLSL_FBM needs GLSL_SIMPLEX.
     */
    
    /** Sine-free hashes: stable across GPUs, no visible banding. */
    export const GLSL_HASH = /* glsl */ `
    float hash12(vec2 p) {
      vec3 p3 = fract(vec3(p.xyx) * 0.1031);
      p3 += dot(p3, p3.yzx + 33.33);
      return fract((p3.x + p3.y) * p3.z);
    }
    
    vec2 hash22(vec2 p) {
      vec3 p3 = fract(vec3(p.xyx) * vec3(0.1031, 0.1030, 0.0973));
      p3 += dot(p3, p3.yzx + 33.33);
      return fract((p3.xx + p3.yz) * p3.zy);
    }
    `;
    
    /** 2D simplex noise in roughly [-1, 1]. */
    export const GLSL_SIMPLEX = /* glsl */ `
    vec2 simplexGradient(vec2 cell) {
      float angle = hash12(cell) * 6.28318530718;
      return vec2(cos(angle), sin(angle));
    }
    
    float snoise(vec2 p) {
      const float F2 = 0.36602540378; // (sqrt(3) - 1) / 2
      const float G2 = 0.21132486540; // (3 - sqrt(3)) / 6
      vec2 cell = floor(p + (p.x + p.y) * F2);
      vec2 a = p - cell + (cell.x + cell.y) * G2;
      vec2 step1 = a.x > a.y ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
      vec2 b = a - step1 + G2;
      vec2 c = a - 1.0 + 2.0 * G2;
      vec3 falloff = max(0.5 - vec3(dot(a, a), dot(b, b), dot(c, c)), 0.0);
      falloff *= falloff;
      falloff *= falloff;
      vec3 contribution = vec3(
        dot(a, simplexGradient(cell)),
        dot(b, simplexGradient(cell + step1)),
        dot(c, simplexGradient(cell + 1.0))
      );
      return 70.0 * dot(falloff, contribution);
    }
    `;
    
    /** Fractal Brownian motion over simplex noise; octaves is clamped to 1–8. Roughly [-1, 1]. */
    export const GLSL_FBM = /* glsl */ `
    float fbm(vec2 p, int octaves) {
      const mat2 rotate = mat2(0.8, 0.6, -0.6, 0.8);
      float sum = 0.0;
      float amplitude = 0.5;
      for (int i = 0; i < 8; i++) {
        if (i >= octaves) break;
        sum += amplitude * snoise(p);
        p = rotate * p * 2.03 + 17.1;
        amplitude *= 0.5;
      }
      return sum;
    }
    `;
    
    /** Ordered (Bayer) dither thresholds in [0, 1) for 2×2, 4×4 and 8×8 matrices. Pass pixel coordinates. */
    export const GLSL_BAYER = /* glsl */ `
    float bayer2(vec2 a) {
      a = floor(a);
      return fract(a.x * 0.5 + a.y * a.y * 0.75);
    }
    
    float bayer4(vec2 a) {
      return bayer2(0.5 * a) * 0.25 + bayer2(a);
    }
    
    float bayer8(vec2 a) {
      return bayer4(0.5 * a) * 0.25 + bayer2(a);
    }
    `;
    
    /** Hash, simplex and fBm together, in dependency order. */
    export const GLSL_NOISE = GLSL_HASH + GLSL_SIMPLEX + GLSL_FBM;
    lib/webgl/use-shader-canvas.ts
    import { useEffect, useRef, useState } from "react";
    
    /** Numbers, vectors (arrays) and, for sampler2D uniforms, an image, canvas or video element. */
    export type UniformValue = number | boolean | readonly number[] | Float32Array | TexImageSource;
    
    const isTextureSource = (value: UniformValue): value is TexImageSource =>
      typeof value === "object" && value !== null && !Array.isArray(value) && !ArrayBuffer.isView(value);
    
    /**
     * - `idle`: not started yet (always the value on the server and the first client render)
     * - `running`: animating
     * - `static`: a single frame is on screen (reduced motion, paused, offscreen or tab hidden)
     * - `fallback`: WebGL2 is unavailable, the shader failed to compile or the context was lost
     */
    export type ShaderStatus = "idle" | "running" | "static" | "fallback";
    
    export interface UseShaderCanvasOptions {
      /**
       * Fragment shader source. Unless it starts with `#version`, the hook prepends a header with
       * `precision highp float`, the built-in uniforms `u_time` (seconds), `u_resolution` (pixels),
       * `u_mouse` (0–1, y up) and `u_pointer` (0–1, eases to 1 while the pointer is inside),
       * `in vec2 v_uv` and `out vec4 fragColor`. Output premultiplied alpha.
       */
      fragmentShader: string;
      /**
       * Custom uniforms by name. Read every frame, so updating them never restarts the shader. Pass a
       * function to compute them per frame from refs or other values that change without re-rendering.
       */
      uniforms?: Record<string, UniformValue> | (() => Record<string, UniformValue>);
      /** Clock multiplier. @default 1 */
      speed?: number;
      /** Freezes the clock and keeps the current frame on screen. @default false */
      paused?: boolean;
      /** Fraction of the display resolution to render at, clamped to 0.5–1. @default 1 */
      renderScale?: number;
      /** Highest device pixel ratio used. @default 1.5 */
      maxDpr?: number;
      /** Track the pointer over the container into `u_mouse` and `u_pointer`. @default false */
      pointer?: boolean;
      /** Clock value, in seconds, of the single frame drawn under reduced motion. @default 6 */
      staticTime?: number;
      /**
       * Render even on software WebGL (SwiftShader, llvmpipe), where compiling and every frame run on
       * the CPU and block the main thread. By default those renderers get the fallback. @default false
       */
      allowSoftware?: boolean;
    }
    
    const VERTEX_SHADER = /* glsl */ `#version 300 es
    out vec2 v_uv;
    void main() {
      vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));
      v_uv = p;
      gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);
    }`;
    
    const FRAGMENT_HEADER = /* glsl */ `#version 300 es
    precision highp float;
    uniform float u_time;
    uniform vec2 u_resolution;
    uniform vec2 u_mouse;
    uniform float u_pointer;
    in vec2 v_uv;
    out vec4 fragColor;
    `;
    
    interface UniformSlot {
      location: WebGLUniformLocation;
      type: number;
    }
    
    const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
    
    function compile(gl: WebGL2RenderingContext, type: number, source: string) {
      const shader = gl.createShader(type);
      if (!shader) return null;
      gl.shaderSource(shader, source);
      gl.compileShader(shader);
      if (gl.getShaderParameter(shader, gl.COMPILE_STATUS)) return shader;
      if (process.env.NODE_ENV !== "production") console.error(`useShaderCanvas: shader failed to compile\n${gl.getShaderInfoLog(shader)}`);
      gl.deleteShader(shader);
      return null;
    }
    
    function link(gl: WebGL2RenderingContext, fragmentSource: string) {
      const source = fragmentSource.trimStart().startsWith("#version") ? fragmentSource.trimStart() : FRAGMENT_HEADER + fragmentSource;
      const vertex = compile(gl, gl.VERTEX_SHADER, VERTEX_SHADER);
      const fragment = compile(gl, gl.FRAGMENT_SHADER, source);
      const program = vertex && fragment ? gl.createProgram() : null;
      if (program && vertex && fragment) {
        gl.attachShader(program, vertex);
        gl.attachShader(program, fragment);
        gl.linkProgram(program);
        gl.detachShader(program, vertex);
        gl.detachShader(program, fragment);
      }
      if (vertex) gl.deleteShader(vertex);
      if (fragment) gl.deleteShader(fragment);
      if (!program) return null;
      if (gl.getProgramParameter(program, gl.LINK_STATUS)) return program;
      if (process.env.NODE_ENV !== "production") console.error(`useShaderCanvas: program failed to link\n${gl.getProgramInfoLog(program)}`);
      gl.deleteProgram(program);
      return null;
    }
    
    function collectUniforms(gl: WebGL2RenderingContext, program: WebGLProgram) {
      const slots = new Map<string, UniformSlot>();
      const count = gl.getProgramParameter(program, gl.ACTIVE_UNIFORMS) as number;
      for (let i = 0; i < count; i++) {
        const info = gl.getActiveUniform(program, i);
        const location = info && gl.getUniformLocation(program, info.name);
        if (info && location) slots.set(info.name.replace(/\[0\]$/, ""), { location, type: info.type });
      }
      return slots;
    }
    
    function setUniform(gl: WebGL2RenderingContext, { location, type }: UniformSlot, value: Exclude<UniformValue, TexImageSource>) {
      const isInt = type === gl.INT || type === gl.BOOL;
      if (typeof value === "boolean" || typeof value === "number") {
        const n = Number(value);
        if (isInt) gl.uniform1i(location, n);
        else gl.uniform1f(location, n);
        return;
      }
      const list = value as number[] | Float32Array;
      if (isInt) gl.uniform1iv(location, Int32Array.from(list));
      else if (type === gl.FLOAT_VEC2) gl.uniform2fv(location, list);
      else if (type === gl.FLOAT_VEC3) gl.uniform3fv(location, list);
      else if (type === gl.FLOAT_VEC4) gl.uniform4fv(location, list);
      else gl.uniform1fv(location, list);
    }
    
    /**
     * Renders a fragment shader with WebGL2 on a canvas the hook creates inside the element you
     * attach `ref` to (give that element a size and `position: relative`, `absolute` or `fixed`).
     *
     * One fullscreen triangle, no three.js. The canvas follows the element's size, caps the pixel
     * ratio, pauses offscreen and in hidden tabs, draws a single static frame under reduced motion
     * (software WebGL such as SwiftShader gets the fallback unless allowSoftware is set),
     * recovers from context loss and releases the GPU context on unmount. Uniform values can be
     * numbers, arrays (vec2–vec4 or float arrays) or, for sampler2D uniforms, an image, canvas or
     * video element (uploaded once it has pixels; pass a new element or re-render to refresh). When `status` is
     * `fallback` (or before the first frame), whatever you render behind the canvas shows through.
     */
    export function useShaderCanvas<T extends HTMLElement = HTMLDivElement>(options: UseShaderCanvasOptions) {
      const ref = useRef<T>(null);
      const [status, setStatus] = useState<ShaderStatus>("idle");
      const latest = useRef(options);
      const wake = useRef<() => void>(() => {});
      const time = useRef(0);
    
      // Keep the render loop reading fresh props, and let it react to paused/uniform changes.
      useEffect(() => {
        latest.current = options;
        wake.current();
      });
    
      const { fragmentShader } = options;
    
      useEffect(() => {
        const host = ref.current;
        if (!host) return;
    
        const canvas = document.createElement("canvas");
        canvas.setAttribute("aria-hidden", "true");
        Object.assign(canvas.style, {
          position: "absolute",
          inset: "0",
          width: "100%",
          height: "100%",
          display: "block",
          pointerEvents: "none",
          opacity: "0",
          transition: "opacity 500ms ease-out",
        });
        host.appendChild(canvas);
    
        const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
        const mouse = [0.5, 0.5];
        const mouseTarget = [0.5, 0.5];
        let gl: WebGL2RenderingContext | null = null;
        let program: WebGLProgram | null = null;
        let uniforms = new Map<string, UniformSlot>();
        const textures = new Map<string, { texture: WebGLTexture; unit: number; source: TexImageSource | null; ready: boolean }>();
        let reported: ShaderStatus = "idle";
        let raf = 0;
        let lastFrame = 0;
        let clock = 0;
        let pointer = 0;
        let pointerTarget = 0;
        let inView = false;
        let pageVisible = document.visibilityState === "visible";
        let needsResize = true;
        let drawn = false;
        let lost = false;
        let software = false;
    
        const report = (next: ShaderStatus) => {
          if (next === reported) return;
          reported = next;
          setStatus(next);
        };
    
        const build = () => {
          gl ??= canvas.getContext("webgl2", {
            alpha: true,
            antialias: false,
            depth: false,
            stencil: false,
            premultipliedAlpha: true,
            powerPreference: "low-power",
          });
          if (gl) {
            const info = gl.getExtension("WEBGL_debug_renderer_info");
            const renderer = info ? String(gl.getParameter(info.UNMASKED_RENDERER_WEBGL)) : String(gl.getParameter(gl.RENDERER));
            software = /swiftshader|llvmpipe|softpipe|software|basic render/i.test(renderer);
          }
          // On software WebGL even compiling blocks the main thread, so use the CSS fallback instead.
          program = gl && !(software && !latest.current.allowSoftware) ? link(gl, fragmentShader) : null;
          uniforms = gl && program ? collectUniforms(gl, program) : new Map();
          textures.clear();
          needsResize = true;
          return program !== null;
        };
    
        const resize = () => {
          if (!gl) return;
          const { renderScale = 1, maxDpr = 1.5 } = latest.current;
          const scale = Math.min(window.devicePixelRatio || 1, maxDpr) * clamp(renderScale, 0.5, 1);
          const width = Math.max(1, Math.round(host.clientWidth * scale));
          const height = Math.max(1, Math.round(host.clientHeight * scale));
          if (canvas.width !== width || canvas.height !== height) {
            canvas.width = width;
            canvas.height = height;
          }
          gl.viewport(0, 0, width, height);
          needsResize = false;
        };
    
        const draw = (time: number) => {
          if (!gl || !program || lost) return;
          if (needsResize) resize();
          gl.useProgram(program);
          const set = (name: string, value: UniformValue) => {
            const slot = uniforms.get(name);
            if (!slot || !gl) return;
            if (isTextureSource(value)) bindTexture(name, slot, value);
            else setUniform(gl, slot, value);
          };
          set("u_time", time);
          set("u_resolution", [canvas.width, canvas.height]);
          set("u_mouse", mouse);
          set("u_pointer", pointer);
          const source = latest.current.uniforms;
          const custom = typeof source === "function" ? source() : source;
          if (custom) for (const name in custom) set(name, custom[name]);
          gl.drawArrays(gl.TRIANGLES, 0, 3);
          if (!drawn) {
            drawn = true;
            canvas.style.opacity = "1";
          }
        };
    
        // Textures upload once per source (and every frame for video), as soon as the source has pixels.
        const bindTexture = (name: string, slot: UniformSlot, source: TexImageSource) => {
          if (!gl || slot.type !== gl.SAMPLER_2D) return;
          let entry = textures.get(name);
          if (!entry) {
            const texture = gl.createTexture();
            if (!texture) return;
            entry = { texture, unit: textures.size, source: null, ready: false };
            textures.set(name, entry);
          }
          gl.activeTexture(gl.TEXTURE0 + entry.unit);
          gl.bindTexture(gl.TEXTURE_2D, entry.texture);
          const hasPixels =
            typeof HTMLImageElement !== "undefined" && source instanceof HTMLImageElement
              ? source.complete && source.naturalWidth > 0
              : typeof HTMLVideoElement !== "undefined" && source instanceof HTMLVideoElement
                ? source.readyState >= 2
                : true;
          const isVideo = typeof HTMLVideoElement !== "undefined" && source instanceof HTMLVideoElement;
          if (hasPixels && (entry.source !== source || !entry.ready || isVideo)) {
            gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
            gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true);
            gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source);
            gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
            gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
            gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
            gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
            entry.source = source;
            entry.ready = true;
          }
          gl.uniform1i(slot.location, entry.unit);
        };
    
        const still = () => reducedMotion.matches;
        const shouldRun = () => !lost && program !== null && inView && pageVisible && !latest.current.paused && !still();
    
        const frame = (now: number) => {
          raf = 0;
          if (!shouldRun()) return settle();
          const dt = lastFrame ? Math.min((now - lastFrame) / 1000, 0.1) : 0;
          lastFrame = now;
          clock += dt * (latest.current.speed ?? 1);
          const ease = 1 - Math.exp(-dt * 6);
          mouse[0] += (mouseTarget[0] - mouse[0]) * ease;
          mouse[1] += (mouseTarget[1] - mouse[1]) * ease;
          pointer += (pointerTarget - pointer) * ease;
          draw(clock);
          time.current = clock;
          report("running");
          raf = requestAnimationFrame(frame);
        };
    
        // Not animating: keep one up-to-date frame on screen.
        const settle = () => {
          lastFrame = 0;
          if (lost || !program) return;
          time.current = still() ? (latest.current.staticTime ?? 6) : clock;
          draw(time.current);
          report("static");
        };
    
        const update = () => {
          if (shouldRun()) {
            if (!raf) raf = requestAnimationFrame(frame);
            return;
          }
          if (raf) cancelAnimationFrame(raf);
          raf = 0;
          settle();
        };
    
        const intersection = new IntersectionObserver(
          ([entry]) => {
            inView = entry.isIntersecting;
            update();
          },
          { rootMargin: "120px" },
        );
        const resizeObserver = new ResizeObserver(() => {
          needsResize = true;
          if (!raf) settle();
        });
        const onVisibility = () => {
          pageVisible = document.visibilityState === "visible";
          update();
        };
        const onPointerMove = (event: PointerEvent) => {
          const rect = host.getBoundingClientRect();
          const x = (event.clientX - rect.left) / rect.width;
          const y = 1 - (event.clientY - rect.top) / rect.height;
          const inside = x >= 0 && x <= 1 && y >= 0 && y <= 1;
          if (inside) {
            mouseTarget[0] = x;
            mouseTarget[1] = y;
          }
          pointerTarget = inside ? 1 : 0;
        };
        const onPointerLeave = () => {
          pointerTarget = 0;
        };
        const onContextLost = (event: Event) => {
          event.preventDefault();
          lost = true;
          if (raf) cancelAnimationFrame(raf);
          raf = 0;
          report("fallback");
        };
        const onContextRestored = () => {
          lost = false;
          if (build()) update();
          else report("fallback");
        };
        const trackPointer = latest.current.pointer === true;
    
        // Start on the next frame: keeps setup out of the effect body and off the hydration task.
        const start = requestAnimationFrame(() => {
          if (!build()) return report("fallback");
          canvas.addEventListener("webglcontextlost", onContextLost);
          canvas.addEventListener("webglcontextrestored", onContextRestored);
          document.addEventListener("visibilitychange", onVisibility);
          reducedMotion.addEventListener("change", update);
          if (trackPointer) {
            window.addEventListener("pointermove", onPointerMove, { passive: true });
            document.documentElement.addEventListener("pointerleave", onPointerLeave);
          }
          intersection.observe(host);
          resizeObserver.observe(host);
          wake.current = update;
        });
    
        return () => {
          cancelAnimationFrame(start);
          if (raf) cancelAnimationFrame(raf);
          wake.current = () => {};
          intersection.disconnect();
          resizeObserver.disconnect();
          canvas.removeEventListener("webglcontextlost", onContextLost);
          canvas.removeEventListener("webglcontextrestored", onContextRestored);
          document.removeEventListener("visibilitychange", onVisibility);
          reducedMotion.removeEventListener("change", update);
          window.removeEventListener("pointermove", onPointerMove);
          document.documentElement.removeEventListener("pointerleave", onPointerLeave);
          if (gl) {
            textures.forEach((entry) => gl?.deleteTexture(entry.texture));
            if (program) gl.deleteProgram(program);
            gl.getExtension("WEBGL_lose_context")?.loseContext();
          }
          canvas.remove();
        };
      }, [fragmentShader]);
    
      /** `time` holds the shader clock (seconds, as passed to u_time), e.g. to timestamp clicks. */
      return { ref, status, time };
    }
  3. Copy the source

    components/ui/cloud-shader/cloud-shader.tsx
    "use client";
    
    import type { ComponentProps, ReactNode } from "react";
    import { cn } from "@/lib/utils";
    import { hexToVec3 } from "@/lib/color";
    import { GLSL_NOISE } from "@/lib/webgl/noise.glsl";
    import { useShaderCanvas } from "@/lib/webgl/use-shader-canvas";
    
    export type CloudQuality = "low" | "medium" | "high";
    
    export interface CloudShaderProps extends Omit<ComponentProps<"div">, "children"> {
      /** Content rendered above the sky. */
      children?: ReactNode;
      /** Drift speed multiplier. @default 1 */
      speed?: number;
      /** Cloud cover from 1 (a few wisps) to 6 (overcast). @default 4 */
      density?: number;
      /** Cloud color. @default "#FFFFFF" */
      cloudColor?: string;
      /** Sky color at the top. @default "#CFE3F7" */
      skyTopColor?: string;
      /** Sky color at the bottom. @default "#F7FBFF" */
      skyBottomColor?: string;
      /** Fade the bottom edge into the page background. @default true */
      fadeToBackground?: boolean;
      /** Clouds drift subtly toward the cursor. @default false */
      interactive?: boolean;
      /** Noise detail and render resolution. @default "medium" */
      quality?: CloudQuality;
      /** Freeze the sky on the current frame. @default false */
      paused?: boolean;
    }
    
    const QUALITY: Record<CloudQuality, { octaves: number; scale: number }> = {
      low: { octaves: 3, scale: 0.5 },
      medium: { octaves: 5, scale: 0.6 },
      high: { octaves: 6, scale: 0.8 },
    };
    
    const fragment = (octaves: number) => /* glsl */ `
    ${GLSL_NOISE}
    uniform vec3 u_sky_top;
    uniform vec3 u_sky_bottom;
    uniform vec3 u_cloud;
    uniform float u_density;
    uniform float u_fade;
    uniform float u_interactive;
    
    // One cloud layer: a broad shape field (where clouds form) plus fBm detail (their puffy edges).
    float clouds(vec2 p, float t, float scale, float drift, float coverage) {
      vec2 q = p * scale + vec2(t * drift, 0.0);
      q += 0.18 * vec2(fbm(q * 0.6 + t * 0.01, 2), fbm(q * 0.6 + 5.3, 2));
      float shape = fbm(q * 0.5 + vec2(3.0, 9.0), 2);
      float detail = fbm(q * 1.6, ${octaves});
      float n = (shape * 0.8 + detail * 0.35) * 0.5 + 0.5;
      float c = smoothstep(coverage, coverage + 0.22, n);
      return c * c * (3.0 - 2.0 * c);
    }
    
    void main() {
      vec2 uv = gl_FragCoord.xy / u_resolution;
      vec2 p = vec2(uv.x * u_resolution.x / u_resolution.y, uv.y);
      p += (u_mouse - 0.5) * 0.12 * u_pointer * u_interactive;
      float t = u_time;
    
      vec3 sky = mix(u_sky_bottom, u_sky_top, smoothstep(0.0, 1.0, uv.y));
      float coverage = mix(0.62, 0.34, clamp((u_density - 1.0) / 5.0, 0.0, 1.0));
    
      // Far layer: smaller, slower, hazier. Near layer: bigger and faster (parallax).
      float far = clouds(p + vec2(3.1, 0.2), t, 2.0, 0.010, coverage + 0.05);
      float near = clouds(p, t, 1.0, 0.026, coverage);
      // Lighting: compare with a sample slightly above, so undersides shade darker and interiors stay bright.
      float above = clouds(p + vec2(0.0, 0.04), t, 1.0, 0.026, coverage);
      float light = clamp(0.96 + (near - above) * 3.2, 0.0, 1.0);
    
      vec3 shadow = mix(u_cloud, u_sky_top, 0.5) * 0.96;
      vec3 color = mix(sky, mix(u_cloud, u_sky_top, 0.35), far * 0.6);
      color = mix(color, mix(shadow, u_cloud, light), near);
    
      float alpha = u_fade > 0.5 ? smoothstep(0.0, 0.34, uv.y) : 1.0;
      fragColor = vec4(color * alpha, alpha);
    }
    `;
    
    const SHADERS = { low: fragment(QUALITY.low.octaves), medium: fragment(QUALITY.medium.octaves), high: fragment(QUALITY.high.octaves) };
    
    export function CloudShader({
      children,
      speed = 1,
      density = 4,
      cloudColor = "#FFFFFF",
      skyTopColor = "#CFE3F7",
      skyBottomColor = "#F7FBFF",
      fadeToBackground = true,
      interactive = false,
      quality = "medium",
      paused = false,
      className,
      style,
      ...props
    }: CloudShaderProps) {
      const { ref } = useShaderCanvas<HTMLDivElement>({
        fragmentShader: SHADERS[quality],
        renderScale: QUALITY[quality].scale,
        speed,
        paused,
        pointer: interactive,
        staticTime: 40,
        uniforms: {
          u_sky_top: hexToVec3(skyTopColor),
          u_sky_bottom: hexToVec3(skyBottomColor),
          u_cloud: hexToVec3(cloudColor),
          u_density: density,
          u_fade: fadeToBackground ? 1 : 0,
          u_interactive: interactive ? 1 : 0,
        },
      });
    
      // Fallback (and first paint): the same sky as a CSS gradient, with the same fade.
      const fallback = fadeToBackground
        ? `linear-gradient(to bottom, ${skyTopColor}, ${skyBottomColor} 66%, color-mix(in oklab, ${skyBottomColor} 0%, transparent))`
        : `linear-gradient(to bottom, ${skyTopColor}, ${skyBottomColor})`;
    
      return (
        <div className={cn("relative isolate overflow-hidden", className)} style={{ background: fallback, ...style }} {...props}>
          <div ref={ref} aria-hidden="true" className="absolute inset-0 -z-10" />
          {children}
        </div>
      );
    }

Usage

import { CloudShader } from "@/components/ui/cloud-shader/cloud-shader";

<CloudShader className="min-h-[520px]" density={4} interactive>
  <YourHero />
</CloudShader>

API reference

PropTypeDefaultDescription
childrenReactNode–Content rendered above the sky.
speednumber1Drift speed multiplier.
densitynumber4Cloud cover from 1 (a few wisps) to 6 (overcast).
cloudColorstring"#FFFFFF"Cloud color.
skyTopColorstring"#CFE3F7"Sky color at the top.
skyBottomColorstring"#F7FBFF"Sky color at the bottom.
fadeToBackgroundbooleantrueFade the bottom edge into the page background.
interactivebooleanfalseClouds drift subtly toward the cursor.
qualityCloudQuality"medium"Noise detail and render resolution.
pausedbooleanfalseFreeze the sky on the current frame.

Also accepts every native <div> attribute, including ref and className.

  • Two parallax layers, each a broad, lightly domain-warped shape field plus fBm detail (3, 5 or 6 octaves for low, medium or high quality), with soft lighting on the undersides. Renders at 0.6× resolution by default, capped at a 1.5 pixel ratio.
  • Pauses when scrolled offscreen or when the tab is hidden, and releases its WebGL context on unmount.
  • fadeToBackground fades the bottom edge to transparent, so the sky blends into whatever page background sits behind it.

Accessibility

  • The sky is a decorative canvas (aria-hidden); children render above it as normal content.
  • With reduced motion the shader draws one still frame. Without WebGL2, or on software rendering with no GPU, a CSS gradient in the same colors is shown.
  • Check text contrast against your sky colors: the default palette is light, so use dark text on it.