Subframe
WebGL & shadersNoise dissolve transition

Noise threshold

Two bold primary-color prints exchange places through a continuous, seeded erosion field.

Scrub the reveal in either direction.

Subframe Original · MIT

Add to your project

Component source

Standalone React component. Styles are included.

React only. No additional packages.

Render the default export inside a container with a defined height. Its optional active prop stops movement when false. This example works with React 19; Next.js projects can use it as a client component.

View source code
/**
 * MIT License
 *
 * Copyright (c) 2026 Subframe
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in all
 * copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 * SOFTWARE.
 */

"use client"

import { useEffect, useId, useRef, useState } from "react"

const fragment = `precision highp float;varying vec2 uv;uniform vec2 resolution;uniform float time;uniform float value;

float hash(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453);}
float field(vec2 p){vec2 i=floor(p),f=fract(p);f=f*f*(3.-2.*f);return mix(mix(hash(i),hash(i+vec2(1.,0.)),f.x),mix(hash(i+vec2(0.,1.)),hash(i+1.),f.x),f.y);}
float noise(vec2 p){float n=0.,a=.54;for(int i=0;i<5;i++){n+=a*field(p);p=mat2(.8,-.6,.6,.8)*p*2.05+3.71;a*=.48;}return n;}
float edge(float d){return 1.-smoothstep(-1.5/resolution.y,1.5/resolution.y,d);}
void main(){
 vec2 p=(uv-.5)*vec2(resolution.x/resolution.y,1.);p.y-=.04;
 vec2 a=p+vec2(.105,-.055),b=p-vec2(.13,-.04);
 float circleA=edge(length(a)-.3),circleB=edge(length(b)-.3);
 float waveA=sin((length(a)*26.+atan(a.y,a.x)*.9)*6.28318);
 float waveB=sin((b.x*.72+b.y*.7)*120.);
 float aa=140./resolution.y;
 vec3 blue=vec3(19.,70.,211.)/255.,paper=vec3(245.,241.,232.)/255.,red=vec3(215.,38.,38.)/255.,yellow=vec3(255.,213.,31.)/255.,ink=vec3(21.)/255.;
 vec3 front=blue;
 front=mix(front,paper,circleA*smoothstep(-aa,aa,waveA));
 front=mix(front,red,circleB*smoothstep(-aa,aa,waveB));
 vec3 back=yellow;
 float shell=edge(abs(length(p)-.247)-.043);
 float bar=edge(abs(p.x*.78+p.y*.63)-.056)*edge(abs(p.y)-.29);
 back=mix(back,ink,max(shell,bar));
 // Coverage has a fixed seed and no clock dependence. Endpoints are exact.
 float threshold=noise(p*5.8+vec2(13.4,8.2));
 float reveal=smoothstep(threshold-.028,threshold+.028,mix(-.1,1.1,value));
 vec3 color=mix(front,back,reveal);
 float grain=(hash(gl_FragCoord.xy)-.5)*.015;
 gl_FragColor=vec4(color+grain,1.);
}`

export default function NoiseThreshold({ active = true }: { active?: boolean }) {
  const canvas = useRef<HTMLCanvasElement>(null),
    elapsed = useRef(0)
  const [value, setValue] = useState(42),
    [playing] = useState(false)
  const [available, setAvailable] = useState(false),
    [, setReduced] = useState(false)
  const id = useId(),
    control = useRef<(() => void) | null>(null)
  const live = useRef({ active, value, playing })
  live.current = { active, value, playing }

  useEffect(() => {
    const element = canvas.current!
    const gl = element.getContext("webgl", { alpha: false, antialias: false, powerPreference: "low-power" })
    if (!gl) return
    const preference = matchMedia("(prefers-reduced-motion: reduce)")
    let reduced = preference.matches,
      disposed = false,
      lost = false,
      inView = true,
      frame = 0,
      last = 0
    let program: WebGLProgram | null = null,
      buffer: WebGLBuffer | null = null
    let shaders: WebGLShader[] = []
    let resolution: WebGLUniformLocation | null = null,
      clock: WebGLUniformLocation | null = null,
      adjustment: WebGLUniformLocation | null = null
    const stop = () => {
      cancelAnimationFrame(frame)
      frame = 0
      last = 0
    }
    const release = () => {
      stop()
      shaders.forEach((shader) => gl.deleteShader(shader))
      shaders = []
      if (buffer) gl.deleteBuffer(buffer)
      if (program) gl.deleteProgram(program)
      program = null
      buffer = null
    }
    const paint = () => {
      if (disposed || lost || !program || document.hidden) return
      gl.viewport(0, 0, element.width, element.height)
      gl.uniform2f(resolution, element.width, element.height)
      gl.uniform1f(clock, elapsed.current)
      gl.uniform1f(adjustment, live.current.value / 100)
      gl.drawArrays(gl.TRIANGLES, 0, 6)
    }
    const tick = (now: number) => {
      frame = 0
      if (disposed || lost || reduced || !inView || !live.current.active || !live.current.playing || document.hidden) {
        last = 0
        return
      }
      if (last) elapsed.current += Math.min((now - last) / 1000, 0.04)
      last = now
      paint()
      frame = requestAnimationFrame(tick)
    }
    const sync = () => {
      stop()
      paint()
      if (
        program &&
        !disposed &&
        !lost &&
        !reduced &&
        inView &&
        live.current.active &&
        live.current.playing &&
        !document.hidden
      )
        frame = requestAnimationFrame(tick)
    }
    const resize = () => {
      const rect = element.getBoundingClientRect()
      if (!rect.width || !rect.height) return
      const dpr = Math.min(devicePixelRatio || 1, 2, Math.sqrt(2400000 / (rect.width * rect.height)))
      const width = Math.max(1, Math.round(rect.width * dpr)),
        height = Math.max(1, Math.round(rect.height * dpr))
      if (element.width !== width || element.height !== height) {
        element.width = width
        element.height = height
      }
      paint()
    }
    const initialize = () => {
      release()
      try {
        program = gl.createProgram()
        if (!program) throw new Error("Unable to create program")
        for (const [type, source] of [
          [
            gl.VERTEX_SHADER,
            "attribute vec2 position;varying vec2 uv;void main(){uv=position*.5+.5;gl_Position=vec4(position,0.,1.);}",
          ],
          [gl.FRAGMENT_SHADER, fragment],
        ] as const) {
          const shader = gl.createShader(type)
          if (!shader) throw new Error("Unable to create shader")
          shaders.push(shader)
          gl.shaderSource(shader, source)
          gl.compileShader(shader)
          if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS))
            throw new Error(gl.getShaderInfoLog(shader) || "Shader compilation failed")
          gl.attachShader(program, shader)
        }
        gl.linkProgram(program)
        if (!gl.getProgramParameter(program, gl.LINK_STATUS)) throw new Error("Shader linking failed")
        gl.useProgram(program)
        buffer = gl.createBuffer()
        if (!buffer) throw new Error("Unable to create buffer")
        gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
        gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]), gl.STATIC_DRAW)
        const position = gl.getAttribLocation(program, "position")
        gl.enableVertexAttribArray(position)
        gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0)
        resolution = gl.getUniformLocation(program, "resolution")
        clock = gl.getUniformLocation(program, "time")
        adjustment = gl.getUniformLocation(program, "value")
        setAvailable(true)
        resize()
        sync()
      } catch {
        release()
        setAvailable(false)
      }
    }
    const changePreference = () => {
      reduced = preference.matches
      setReduced(reduced)
      sync()
    }
    const contextLost = (event: Event) => {
      event.preventDefault()
      lost = true
      stop()
      setAvailable(false)
    }
    const contextRestored = () => {
      lost = false
      initialize()
    }
    const observer = new ResizeObserver(resize),
      transforms = new MutationObserver(resize)
    for (let node: HTMLElement | null = element.parentElement; node; node = node.parentElement) {
      observer.observe(node)
      transforms.observe(node, { attributes: true, attributeFilter: ["style", "class"] })
    }
    observer.observe(element)
    const intersection = new IntersectionObserver(([entry]) => {
      inView = entry.isIntersecting
      sync()
    })
    intersection.observe(element)
    preference.addEventListener("change", changePreference)
    element.addEventListener("webglcontextlost", contextLost)
    element.addEventListener("webglcontextrestored", contextRestored)
    document.addEventListener("visibilitychange", sync)
    window.addEventListener("resize", resize)
    control.current = sync
    setReduced(reduced)
    initialize()
    return () => {
      disposed = true
      control.current = null
      observer.disconnect()
      intersection.disconnect()
      transforms.disconnect()
      preference.removeEventListener("change", changePreference)
      element.removeEventListener("webglcontextlost", contextLost)
      element.removeEventListener("webglcontextrestored", contextRestored)
      document.removeEventListener("visibilitychange", sync)
      window.removeEventListener("resize", resize)
      release()
      // StrictMode replays effects while connected; only a real removal releases the context.
      queueMicrotask(() => {
        if (!element.isConnected) gl.getExtension("WEBGL_lose_context")?.loseContext()
      })
    }
  }, [])
  useEffect(() => control.current?.(), [active, value, playing])

  return (
    <div className="sf-noise-threshold" data-shader-ready={available}>
      <svg
        className="sf-noise-threshold-fallback"
        viewBox="0 0 800 500"
        preserveAspectRatio="xMidYMid slice"
        aria-hidden="true"
      >
        <rect width="800" height="500" fill="oklch(0.4670 0.2216 264.01)" />
        <circle cx="340" cy="240" r="130" fill="none" stroke="oklch(0.9588 0.0127 86.83)" strokeWidth="70" />
        <path d="M410 90l95 280" stroke="oklch(0.5680 0.2107 27.24)" strokeWidth="120" />
      </svg>
      <canvas
        ref={canvas}
        className="sf-noise-threshold-canvas"
        style={{ opacity: available ? 1 : 0 }}
        aria-label="Noise threshold material study"
        role="img"
      />
      <div className="sf-noise-threshold-controls">
        <label htmlFor={id}>Reveal</label>
        <input
          id={id}
          aria-label="Reveal"
          type="range"
          disabled={!available}
          min="0"
          max="100"
          value={value}
          onChange={(event) => setValue(Number(event.target.value))}
        />
        <output htmlFor={id}>{value + "%"}</output>
      </div>
      <style>{`
        .sf-noise-threshold { width:100%; height:100%; min-height:0; position:relative; overflow:hidden; background:oklch(0.4670 0.2216 264.01); color:oklch(0.1957 0 0); font-family:Arial,sans-serif; isolation:isolate; }
        .sf-noise-threshold-fallback,.sf-noise-threshold-canvas { position:absolute; inset:0; width:100%; height:100%; display:block; pointer-events:none; }
        .sf-noise-threshold-controls { position:absolute; bottom:7%; left:50%; transform:translateX(-50%); display:flex; gap:14px; align-items:center; width:min(78%,390px); font-size:11px; line-height:1; background:oklch(0.9588 0.0127 86.83); box-shadow:0 0 0 8px oklch(0.9588 0.0127 86.83); }
        .sf-noise-threshold-controls label { white-space:nowrap; }
        .sf-noise-threshold-controls input { width:100%; min-width:40px; height:44px; margin:0; accent-color:oklch(0.1957 0 0); cursor:ew-resize; }
        .sf-noise-threshold-controls output { min-width:42px; text-align:right; font-variant-numeric:tabular-nums; white-space:nowrap; }
        .sf-noise-threshold-controls button { width:44px; height:44px; flex:none; border:1px solid currentColor; border-radius:50%; background:transparent; color:inherit; padding:11px; cursor:pointer; transition:opacity 140ms ease,transform 140ms ease; }
        .sf-noise-threshold-controls button svg { width:20px; height:20px; display:block; }
        .sf-noise-threshold-controls button:active { transform:scale(.97); }
        .sf-noise-threshold-controls button:disabled { opacity:.4; cursor:default; }
        .sf-noise-threshold-controls :focus-visible { outline:2px solid currentColor; outline-offset:4px; }
        @media (prefers-reduced-motion:reduce) { .sf-noise-threshold-controls button { transition:none; } }
      `}</style>
    </div>
  )
}

Where it works well

Scrub or reverse an organic reveal at exact progress values without changing the underlying pattern.

Reduced motion

The composition only updates on direct input; there is no autonomous motion or animated easing.

Things to know

A fixed five-octave value-noise field masks two original procedural compositions. No images or video are sampled. Requires WebGL 1; static SVG artwork appears during failure or context loss, and the renderer rebuilds on restoration.

More examples