Subframe
WebGL & shadersSlit-scan image shader

Slit scan

A porcelain frame stretches into an impossible ribbon as each image column samples a different instant.

Change time spread or pause the scan.

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 box(vec2 p,vec2 s,float r){vec2 q=abs(p)-s+r;return min(max(q.x,q.y),0.)+length(max(q,0.))-r;}
float edge(float d){return 1.-smoothstep(-1.4/resolution.y,1.4/resolution.y,d);}
void main(){
 vec2 p=(uv-.5)*vec2(resolution.x/resolution.y,1.);p.y-=.035;
 // Each column samples a different instant of the same rigid motion.
 // Zero spread is an ordinary frame, not a second effect or alternate mesh.
 float sampleTime=time*.52+(uv.x-.5)*value*15.;
 float angle=sampleTime*.85+.48;
 mat2 rotation=mat2(cos(angle),-sin(angle),sin(angle),cos(angle));
 vec2 q=rotation*(p-vec2(sin(sampleTime*.71)*.14,cos(sampleTime*.47)*.045));
 float depth=.66+.24*cos(sampleTime*.63);q.x/=depth;
 float outer=box(q,vec2(.26,.25),.08),inner=box(q,vec2(.167,.157),.022);
 float shape=max(outer,-inner);
 vec3 background=vec3(.027,.235,.245);
 float shadow=edge(max(box(q+vec2(-.018,.025),vec2(.263,.253),.08),-box(q+vec2(-.018,.025),vec2(.164,.154),.022)));
 background*=1.-shadow*.27;
 float facing=clamp(.52+q.x*.8-q.y*.34,0.,1.);
 vec3 ceramic=mix(vec3(.56,.68,.64),vec3(.99,.96,.83),facing);
 float bevel=exp(-abs(shape)*130.);
 ceramic+=bevel*.17;
 vec3 color=mix(background,ceramic,edge(shape));
 float channel=edge(abs(q.y)-.0012)*edge(abs(q.x)-.23)*edge(-abs(q.x)+.17);
 color=mix(color,vec3(.25,.42,.39),channel*.55);
 gl_FragColor=vec4(color,1.);
}`

export default function SlitScan({ active = true }: { active?: boolean }) {
  const canvas = useRef<HTMLCanvasElement>(null),
    elapsed = useRef(0)
  const [value, setValue] = useState(66),
    [playing, setPlaying] = useState(true)
  const [available, setAvailable] = useState(false),
    [reduced, 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-slit-scan" data-shader-ready={available}>
      <svg
        className="sf-slit-scan-fallback"
        viewBox="0 0 800 500"
        preserveAspectRatio="xMidYMid slice"
        aria-hidden="true"
      >
        <path
          d="M250 340C410 420 180 110 380 140S350 440 560 195"
          fill="none"
          stroke="#e4e8d4"
          strokeWidth="50"
          strokeLinejoin="round"
        />
      </svg>
      <canvas
        ref={canvas}
        className="sf-slit-scan-canvas"
        style={{ opacity: available ? 1 : 0 }}
        aria-label="Slit scan material study"
        role="img"
      />
      <div className="sf-slit-scan-controls">
        <label htmlFor={id}>Time spread</label>
        <input
          id={id}
          aria-label="Time spread"
          type="range"
          disabled={!available}
          min="0"
          max="100"
          value={value}
          onChange={(event) => setValue(Number(event.target.value))}
        />
        <output htmlFor={id}>{(value * 0.15).toFixed(1) + " s"}</output>
        <button
          type="button"
          aria-label={playing ? "Pause animation" : "Play animation"}
          aria-pressed={playing}
          disabled={reduced || !available}
          onClick={() => setPlaying(!playing)}
        >
          {playing && !reduced ? (
            <svg viewBox="0 0 20 20" aria-hidden="true">
              <path d="M7 5v10M13 5v10" stroke="currentColor" strokeWidth="2" />
            </svg>
          ) : (
            <svg viewBox="0 0 20 20" aria-hidden="true">
              <path d="m7 4 9 6-9 6z" fill="currentColor" />
            </svg>
          )}
        </button>
      </div>
      <style>{`
        .sf-slit-scan { width:100%; height:100%; min-height:0; position:relative; overflow:hidden; background:#073c3e; color:#e2ebd4; font-family:Arial,sans-serif; isolation:isolate; }
        .sf-slit-scan-fallback,.sf-slit-scan-canvas { position:absolute; inset:0; width:100%; height:100%; display:block; pointer-events:none; }
        .sf-slit-scan-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; }
        .sf-slit-scan-controls label { white-space:nowrap; }
        .sf-slit-scan-controls input { width:100%; min-width:40px; height:44px; margin:0; accent-color:#e2ebd4; cursor:ew-resize; }
        .sf-slit-scan-controls output { min-width:42px; text-align:right; font-variant-numeric:tabular-nums; white-space:nowrap; }
        .sf-slit-scan-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-slit-scan-controls button svg { width:20px; height:20px; display:block; }
        .sf-slit-scan-controls button:active { transform:scale(.97); }
        .sf-slit-scan-controls button:disabled { opacity:.4; cursor:default; }
        .sf-slit-scan-controls :focus-visible { outline:2px solid currentColor; outline-offset:4px; }
        @media (prefers-reduced-motion:reduce) { .sf-slit-scan-controls button { transition:none; } }
      `}</style>
    </div>
  )
}

Where it works well

Create temporal distortion from a rigid animation while keeping the amount of time displacement explicit and controllable.

Reduced motion

The scene clock stops at a fixed pose. The time-spread control updates the static temporal composition directly.

Things to know

Each column evaluates an analytic rigid pose at its own timestamp. It does not capture camera/video history or require feedback textures. Requires WebGL 1; static SVG artwork covers unsupported or lost contexts and automatically yields when restored.

More examples