Subframe
WebGL & shadersPaper fold shader

Crease reveal

An acid-yellow poster compresses into a faceted paper object and unfolds again.

Crumple the paper or adjust the crease.

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, useRef, useState } from "react"

function useCalm(active: boolean) {
  const [reduced, setReduced] = useState(true)
  useEffect(() => {
    const media = matchMedia("(prefers-reduced-motion: reduce)")
    const sync = () => setReduced(media.matches)
    sync()
    media.addEventListener("change", sync)
    return () => media.removeEventListener("change", sync)
  }, [])
  return reduced || !active
}

export default function CreaseReveal({ active = true }: { active?: boolean }) {
  const calm = useCalm(active),
    canvas = useRef<HTMLCanvasElement>(null),
    amount = useRef(0.25),
    target = useRef(0.25),
    kick = useRef<(instant?: boolean) => void>(() => {}),
    [fold, setFold] = useState(25),
    [failed, setFailed] = useState(false)
  useEffect(() => {
    const el = canvas.current
    if (!el) return
    const gl = el.getContext("webgl", { alpha: true, antialias: true })
    if (!gl) {
      setFailed(true)
      return
    }
    let raf = 0,
      disposed = false
    const shaders: WebGLShader[] = []
    const compile = (type: number, source: string) => {
      const s = gl.createShader(type)!
      gl.shaderSource(s, source)
      gl.compileShader(s)
      if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
        gl.deleteShader(s)
        throw Error("Unable to draw paper")
      }
      shaders.push(s)
      return s
    }
    let program: WebGLProgram | null = null
    try {
      program = gl.createProgram()!
      gl.attachShader(
        program,
        compile(
          gl.VERTEX_SHADER,
          `attribute vec3 a_position;attribute vec3 a_normal;attribute vec2 a_uv;uniform float u_aspect;varying vec3 v_normal;varying vec2 v_uv;void main(){float perspective=2.8/(2.8-a_position.z);gl_Position=vec4(a_position.x/u_aspect*perspective,a_position.y*perspective,a_position.z*.15,1.);v_normal=a_normal;v_uv=a_uv;}`,
        ),
      )
      gl.attachShader(
        program,
        compile(
          gl.FRAGMENT_SHADER,
          `precision mediump float;uniform sampler2D u_paper;varying vec3 v_normal;varying vec2 v_uv;void main(){vec3 n=normalize(v_normal);float light=.42+.58*abs(dot(n,normalize(vec3(-.35,.45,1.))));vec3 ink=texture2D(u_paper,v_uv).rgb;gl_FragColor=vec4(ink*light,1.);}`,
        ),
      )
      gl.linkProgram(program)
      if (!gl.getProgramParameter(program, gl.LINK_STATUS)) throw Error("Unable to link paper")
    } catch {
      shaders.forEach((s) => gl.deleteShader(s))
      if (program) gl.deleteProgram(program)
      setFailed(true)
      return
    }
    shaders.forEach((s) => gl.deleteShader(s))
    gl.useProgram(program)
    gl.enable(gl.DEPTH_TEST)
    gl.clearColor(0, 0, 0, 0)
    const paper = document.createElement("canvas")
    paper.width = 1024
    paper.height = 1360
    const ctx = paper.getContext("2d")!
    ctx.scale(2, 2)
    ctx.fillStyle = "#e0ed47"
    ctx.fillRect(0, 0, 512, 680)
    ctx.fillStyle = "#20241e"
    ctx.font = "bold 146px Arial"
    ctx.fillText("NO", 24, 175)
    ctx.font = "bold 112px Arial"
    ctx.fillText("WASTE", 20, 278)
    ctx.lineWidth = 2
    ctx.strokeStyle = "#20241e"
    ctx.strokeRect(24, 24, 464, 632)
    ctx.font = "16px Arial"
    ctx.fillText("A NEW SHAPE IS STILL A SHAPE.", 28, 615)
    ctx.font = "bold 250px Arial"
    ctx.fillText("↻", 116, 535)
    const texture = gl.createTexture()!
    gl.bindTexture(gl.TEXTURE_2D, texture)
    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, paper)
    for (const p of [gl.TEXTURE_MIN_FILTER, gl.TEXTURE_MAG_FILTER]) gl.texParameteri(gl.TEXTURE_2D, p, gl.LINEAR)
    for (const p of [gl.TEXTURE_WRAP_S, gl.TEXTURE_WRAP_T]) gl.texParameteri(gl.TEXTURE_2D, p, gl.CLAMP_TO_EDGE)
    const buffer = gl.createBuffer()!
    gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
    const N = 28,
      data = new Float32Array(N * N * 6 * 8)
    gl.bufferData(gl.ARRAY_BUFFER, data, gl.DYNAMIC_DRAW)
    for (const [name, size, offset] of [
      ["a_position", 3, 0],
      ["a_normal", 3, 12],
      ["a_uv", 2, 24],
    ] as const) {
      const loc = gl.getAttribLocation(program, name)
      gl.enableVertexAttribArray(loc)
      gl.vertexAttribPointer(loc, size, gl.FLOAT, false, 32, offset)
    }
    const aspect = gl.getUniformLocation(program, "u_aspect")
    const point = (u: number, v: number) => {
      const p = amount.current,
        a = (u - 0.5) * Math.PI * 2.1,
        b = (v - 0.5) * Math.PI * 0.88,
        r = 0.35 + 0.054 * Math.sin(u * 29 + v * 23) + 0.035 * Math.cos(v * 41 - u * 17),
        t = Math.pow(p, 0.78)
      return [
        (u - 0.5) * 1.44 * (1 - t) + Math.sin(a) * Math.cos(b) * r * t,
        (0.5 - v) * 1.84 * (1 - t) - Math.sin(b) * r * 1.25 * t,
        (Math.cos(a) * Math.cos(b) * r + 0.07 * Math.sin(u * 37 + v * 19)) * t,
      ]
    }
    const draw = () => {
      if (disposed || gl.isContextLost()) return
      let at = 0
      const tri = (uv: number[][]) => {
        const a = point(uv[0][0], uv[0][1]),
          b = point(uv[1][0], uv[1][1]),
          c = point(uv[2][0], uv[2][1]),
          ab = b.map((v, i) => v - a[i]),
          ac = c.map((v, i) => v - a[i]),
          normal = [ab[1] * ac[2] - ab[2] * ac[1], ab[2] * ac[0] - ab[0] * ac[2], ab[0] * ac[1] - ab[1] * ac[0]]
        for (let i = 0; i < 3; i++) {
          data.set([...[a, b, c][i], ...normal, ...uv[i]], at)
          at += 8
        }
      }
      for (let j = 0; j < N; j++)
        for (let i = 0; i < N; i++) {
          const u = i / N,
            v = j / N,
            q = 1 / N
          tri([
            [u, v],
            [u + q, v],
            [u, v + q],
          ])
          tri([
            [u + q, v],
            [u + q, v + q],
            [u, v + q],
          ])
        }
      gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
      gl.bufferSubData(gl.ARRAY_BUFFER, 0, data)
      gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)
      gl.drawArrays(gl.TRIANGLES, 0, N * N * 6)
    }
    let last = 0
    const frame = (t: number) => {
      raf = 0
      const dt = last ? Math.min(32, t - last) : 16
      last = t
      amount.current += (target.current - amount.current) * (1 - Math.exp(-dt / 95))
      draw()
      if (Math.abs(target.current - amount.current) > 0.001 && !document.hidden) raf = requestAnimationFrame(frame)
    }
    const start = (instant = false) => {
      cancelAnimationFrame(raf)
      last = 0
      if (calm || instant) {
        amount.current = target.current
        draw()
      } else if (!document.hidden) raf = requestAnimationFrame(frame)
    }
    kick.current = start
    const resize = () => {
      const r = el.getBoundingClientRect(),
        d = Math.min(devicePixelRatio || 1, 2, Math.sqrt(3600000 / Math.max(1, r.width * r.height)))
      const nextWidth = Math.max(1, Math.round(r.width * d))
      const nextHeight = Math.max(1, Math.round(r.height * d))
      if (el.width !== nextWidth || el.height !== nextHeight) {
        el.width = nextWidth
        el.height = nextHeight
      }
      gl.viewport(0, 0, el.width, el.height)
      gl.uniform1f(aspect, r.width / Math.max(1, r.height))
      draw()
    }
    const ro = new ResizeObserver(resize)
    const transforms = new MutationObserver(resize)
    for (let node: HTMLElement | null = el; node; node = node.parentElement) {
      ro.observe(node)
      transforms.observe(node, { attributes: true, attributeFilter: ["style", "class"] })
    }
    window.addEventListener("resize", resize)
    resize()
    if (active) start()
    const visibility = () => {
      if (document.hidden) cancelAnimationFrame(raf)
      else if (active) start()
    }
    const lost = (e: Event) => {
      e.preventDefault()
      cancelAnimationFrame(raf)
      setFailed(true)
    }
    document.addEventListener("visibilitychange", visibility)
    el.addEventListener("webglcontextlost", lost)
    return () => {
      disposed = true
      cancelAnimationFrame(raf)
      ro.disconnect()
      transforms.disconnect()
      window.removeEventListener("resize", resize)
      document.removeEventListener("visibilitychange", visibility)
      el.removeEventListener("webglcontextlost", lost)
      gl.deleteBuffer(buffer)
      gl.deleteTexture(texture)
      gl.deleteProgram(program)
      paper.width = 0
      paper.height = 0
      kick.current = () => {}
    }
  }, [calm, active])
  return (
    <section className="sf-crease-reveal">
      <style>{`.sf-crease-reveal{width:100%;height:100%;min-height:280px;position:relative;container-type:size;background:#20241e;color:#e0ed47;font-family:Arial,sans-serif;overflow:hidden}.sf-crease-reveal canvas{display:block;width:100%;height:calc(100% - 48px)}.sf-crease-reveal footer{position:absolute;bottom:5px;left:18px;right:18px;display:flex;align-items:center;justify-content:space-between;gap:15px;font-size:10px;letter-spacing:.1em}.sf-crease-reveal footer label{display:flex;align-items:center;gap:10px}.sf-crease-reveal input{width:clamp(70px,23cqw,135px);accent-color:#e0ed47}.sf-crease-reveal button{border:1px solid #e0ed4755;background:transparent;color:inherit;min-height:44px;min-width:86px;padding:0 12px;cursor:pointer;font:inherit}.sf-crease-reveal :focus-visible{outline:2px solid #fff;outline-offset:3px}.sf-crease-reveal .cr-fallback{position:absolute;top:18px;bottom:70px;left:50%;transform:translateX(-50%);width:150px;padding:15px;background:#e0ed47;color:#20241e;font-weight:900;font-size:38px;line-height:1}`}</style>
      <canvas ref={canvas} aria-label="An acid-yellow printed sheet compresses into a faceted paper object" />
      {failed && (
        <div className="cr-fallback">
          NO
          <br />
          WASTE
        </div>
      )}
      <footer>
        <label>
          CREASE
          <input
            aria-label="Paper crumple amount"
            type="range"
            min="0"
            max="100"
            value={fold}
            onChange={(e) => {
              const n = +e.target.value
              setFold(n)
              target.current = n / 100
              kick.current(true)
            }}
          />
        </label>
        <button
          onClick={(e) => {
            const n = fold > 50 ? 0 : 100
            setFold(n)
            target.current = n / 100
            kick.current(e.detail === 0)
          }}
        >
          {fold > 50 ? "FLATTEN" : "CRUMPLE"}
        </button>
      </footer>
    </section>
  )
}

Where it works well

Reveal the surface and volume of printed material through one reversible transformation.

Reduced motion

Deliberate controls update a still mesh without interpolation.

Things to know

Requires WebGL 1; a static poster appears if unavailable. A deterministic faceted deformation, not a physically accurate self-colliding sheet.

More examples