Subframe
WebGL & shadersRibbon image transition

Ribbon gallery

A woodblock print separates into narrow, twisting ribbons before the next edition becomes whole.

Drag left across the print or select the next and previous controls.

Subframe Original · MIT

Add to your project

Component source

Standalone React component. Styles are included.

React only. No additional packages.

Download images (3)

Save these files in your static assets folder at /motion/assets/, or update their paths in the component.

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 * as React from "react"

const PRINTS = [
  {
    src: "/motion/assets/suruga-wind.webp",
    title: "Wind",
    alt: "Travelers and reeds swept by wind beneath Mount Fuji",
  },
  { src: "/motion/assets/fuji-storm.webp", title: "Storm", alt: "A red mountain rising above a dramatic black storm" },
  { src: "/motion/assets/plum-garden.webp", title: "Plum", alt: "A curved flowering plum branch against a red sky" },
]
const VERTEX = `precision highp float;attribute vec2 aPosition;attribute vec2 aUv;attribute float aBand;uniform float uProgress;uniform vec2 uScale;varying vec2 vUv;varying float vBand;void main(){vUv=aUv;vBand=aBand;float envelope=sin(uProgress*3.14159265);float edge=pow(abs(aPosition.x),2.5);float wave=sin(aPosition.x*4.0+aBand*1.15+uProgress*4.0);float x=aPosition.x+edge*wave*envelope*.16;float y=aPosition.y+edge*sin(aBand*.63+uProgress*5.0)*envelope*.16;float z=edge*cos(aBand*.72+uProgress*4.0)*envelope*.38;float perspective=1.0/(1.0-z*.35);gl_Position=vec4(x*uScale.x*perspective,y*uScale.y*perspective,0.0,1.0);}`
const FRAGMENT = `precision highp float;uniform sampler2D uFrom;uniform sampler2D uTo;uniform float uProgress;varying vec2 vUv;varying float vBand;void main(){float boundary=uProgress*1.5-.25;float edge=smoothstep(boundary-.11,boundary+.11,vUv.x+sin(vBand*.72)*.09);float blend=1.0-edge;if(uProgress<.001)blend=0.0;if(uProgress>.999)blend=1.0;vec4 color=mix(texture2D(uFrom,vUv),texture2D(uTo,vUv),blend);gl_FragColor=color;}`
export default function Example({ active = true }: { active?: boolean }) {
  const canvas = React.useRef<HTMLCanvasElement>(null),
    draw = React.useRef<() => void>(() => {}),
    request = React.useRef(0),
    current = React.useRef(0),
    next = React.useRef(1),
    progress = React.useRef(0),
    quiet = React.useRef(false),
    allowed = React.useRef(active),
    drag = React.useRef<{ id: number; x: number; start: number } | null>(null)
  const [index, setIndex] = React.useState(0),
    [ready, setReady] = React.useState(false),
    [fallback, setFallback] = React.useState(false),
    [position, setPosition] = React.useState(0)
  const resume = React.useRef<() => void>(() => {})
  allowed.current = active
  React.useEffect(() => {
    const el = canvas.current
    if (!el) return
    const media = matchMedia("(prefers-reduced-motion: reduce)"),
      sync = () => {
        quiet.current = media.matches
        if (media.matches && progress.current > 0) {
          cancelAnimationFrame(request.current)
          commit(next.current)
        }
      }
    sync()
    media.addEventListener("change", sync)
    const gl = el.getContext("webgl", {
      alpha: true,
      antialias: true,
      premultipliedAlpha: false,
      preserveDrawingBuffer: false,
    })
    if (!gl) {
      setFallback(true)
      return () => media.removeEventListener("change", sync)
    }
    let alive = true,
      program: WebGLProgram | null = null,
      buffer: WebGLBuffer | null = null
    const shaders: WebGLShader[] = [],
      textures: WebGLTexture[] = [],
      images: HTMLImageElement[] = []
    try {
      const shader = (kind: number, source: string) => {
        const item = gl.createShader(kind)
        if (!item) throw Error("Shader unavailable")
        shaders.push(item)
        gl.shaderSource(item, source)
        gl.compileShader(item)
        if (!gl.getShaderParameter(item, gl.COMPILE_STATUS)) throw Error("Shader unavailable")
        return item
      }
      program = gl.createProgram()
      if (!program) throw Error("Renderer unavailable")
      gl.attachShader(program, shader(gl.VERTEX_SHADER, VERTEX))
      gl.attachShader(program, shader(gl.FRAGMENT_SHADER, FRAGMENT))
      gl.linkProgram(program)
      if (!gl.getProgramParameter(program, gl.LINK_STATUS)) throw Error("Renderer unavailable")
      gl.useProgram(program)
      const data: number[] = [],
        bands = 26,
        segments = 44
      for (let band = 0; band < bands; band++) {
        for (let segment = 0; segment < segments; segment++) {
          const push = (x: number, y: number) => {
            const u = x / segments,
              v = (band + y) / bands
            data.push(u * 2 - 1, 1 - v * 2, u, 1 - v, band)
          }
          push(segment, 0)
          push(segment + 1, 0)
          push(segment, 1)
          push(segment, 1)
          push(segment + 1, 0)
          push(segment + 1, 1)
        }
      }
      buffer = gl.createBuffer()
      gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
      gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(data), gl.STATIC_DRAW)
      for (const [name, length, offset] of [
        ["aPosition", 2, 0],
        ["aUv", 2, 8],
        ["aBand", 1, 16],
      ] as const) {
        const loc = gl.getAttribLocation(program, name)
        gl.enableVertexAttribArray(loc)
        gl.vertexAttribPointer(loc, length, gl.FLOAT, false, 20, offset)
      }
      const progressUniform = gl.getUniformLocation(program, "uProgress"),
        scaleUniform = gl.getUniformLocation(program, "uScale"),
        fromUniform = gl.getUniformLocation(program, "uFrom"),
        toUniform = gl.getUniformLocation(program, "uTo")
      const loaded = new Set<number>()
      const aspects: number[] = []
      draw.current = () => {
        if (!alive || !program || !loaded.has(current.current) || !loaded.has(next.current)) return
        gl.viewport(0, 0, el.width, el.height)
        gl.clearColor(0, 0, 0, 0)
        gl.clear(gl.COLOR_BUFFER_BIT)
        gl.useProgram(program)
        gl.uniform1f(progressUniform, progress.current)
        const imageAspect =
          aspects[current.current] + (aspects[next.current] - aspects[current.current]) * progress.current
        const canvasAspect = el.width / el.height
        gl.uniform2f(
          scaleUniform,
          0.82 * Math.min(1, imageAspect / canvasAspect),
          0.82 * Math.min(1, canvasAspect / imageAspect),
        )
        gl.activeTexture(gl.TEXTURE0)
        gl.bindTexture(gl.TEXTURE_2D, textures[current.current])
        gl.uniform1i(fromUniform, 0)
        gl.activeTexture(gl.TEXTURE1)
        gl.bindTexture(gl.TEXTURE_2D, textures[next.current])
        gl.uniform1i(toUniform, 1)
        gl.drawArrays(gl.TRIANGLES, 0, data.length / 5)
      }
      PRINTS.forEach((print, i) => {
        const texture = gl.createTexture()
        if (!texture) throw Error("Texture unavailable")
        textures.push(texture)
        const img = new Image()
        images.push(img)
        img.onload = () => {
          if (!alive) return
          try {
            gl.bindTexture(gl.TEXTURE_2D, texture)
            gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1)
            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)
            gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
            gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
            gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img)
            aspects[i] = img.naturalWidth / img.naturalHeight
            loaded.add(i)
            if (loaded.size === PRINTS.length) {
              setReady(true)
              draw.current()
            }
          } catch {
            setFallback(true)
          }
        }
        img.onerror = () => {
          if (alive) setFallback(true)
        }
        img.src = print.src
      })
    } catch {
      setFallback(true)
    }
    const resize = () => {
      const r = el.getBoundingClientRect(),
        dpr = Math.min(devicePixelRatio || 1, 2, Math.sqrt(3600000 / Math.max(1, r.width * r.height)))
      const nextWidth = Math.max(1, Math.round(r.width * dpr))
      const nextHeight = Math.max(1, Math.round(r.height * dpr))
      if (el.width !== nextWidth || el.height !== nextHeight) {
        el.width = nextWidth
        el.height = nextHeight
      }
      draw.current()
    }
    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)
    const visibility = () => {
      if (document.hidden) cancelAnimationFrame(request.current)
      else if (allowed.current && progress.current > 0) resume.current()
    }
    document.addEventListener("visibilitychange", visibility)
    const lost = (event: Event) => {
      event.preventDefault()
      cancelAnimationFrame(request.current)
      setFallback(true)
    }
    el.addEventListener("webglcontextlost", lost)
    return () => {
      alive = false
      cancelAnimationFrame(request.current)
      ro.disconnect()
      transforms.disconnect()
      window.removeEventListener("resize", resize)
      document.removeEventListener("visibilitychange", visibility)
      el.removeEventListener("webglcontextlost", lost)
      media.removeEventListener("change", sync)
      images.forEach((img) => {
        img.onload = null
        img.onerror = null
      })
      textures.forEach((t) => gl.deleteTexture(t))
      shaders.forEach((s) => gl.deleteShader(s))
      if (buffer) gl.deleteBuffer(buffer)
      if (program) gl.deleteProgram(program)
      draw.current = () => {}
    }
  }, [])
  React.useEffect(() => {
    if (!active) {
      cancelAnimationFrame(request.current)
      drag.current = null
    } else if (progress.current > 0) {
      resume.current()
    }
  }, [active])
  function commit(i: number) {
    current.current = (i + PRINTS.length) % PRINTS.length
    next.current = (current.current + 1) % PRINTS.length
    progress.current = 0
    setIndex(current.current)
    setPosition(0)
    draw.current()
  }
  function transition(i: number, instant = false) {
    cancelAnimationFrame(request.current)
    const destination = (i + PRINTS.length) % PRINTS.length
    if (instant || quiet.current || fallback || !ready || !active) {
      commit(destination)
      return
    }
    next.current = destination
    const from = progress.current
    let start = 0
    const tick = (t: number) => {
      if (!allowed.current || document.hidden) return
      start ||= t
      const f = Math.min(1, (t - start) / Math.max(80, 650 * (1 - from))),
        e = f * f * (3 - 2 * f)
      progress.current = from + (1 - from) * e
      setPosition(progress.current)
      draw.current()
      if (f < 1) request.current = requestAnimationFrame(tick)
      else commit(destination)
    }
    request.current = requestAnimationFrame(tick)
  }
  resume.current = () => transition(next.current)
  return (
    <div className="sf-ribbon-gallery">
      <header>
        <b>FORM / EDITIONS</b>
        <span>{String(index + 1).padStart(2, "0")} — 03</span>
      </header>
      <div
        className="rg-art"
        onPointerDown={(e) => {
          if (e.button !== 0 || !active || fallback || !ready || quiet.current) return
          cancelAnimationFrame(request.current)
          next.current = (current.current + 1) % PRINTS.length
          drag.current = { id: e.pointerId, x: e.clientX, start: progress.current }
          e.currentTarget.setPointerCapture(e.pointerId)
        }}
        onPointerMove={(e) => {
          const d = drag.current
          if (!d || d.id !== e.pointerId) return
          progress.current = Math.max(
            0,
            Math.min(0.999, d.start + ((d.x - e.clientX) / e.currentTarget.getBoundingClientRect().width) * 1.5),
          )
          setPosition(progress.current)
          draw.current()
        }}
        onPointerUp={() => {
          if (!drag.current) return
          drag.current = null
          if (progress.current > 0.12) transition(next.current)
          else {
            progress.current = 0
            setPosition(0)
            draw.current()
          }
        }}
        onPointerCancel={() => {
          drag.current = null
          progress.current = 0
          setPosition(0)
          draw.current()
        }}
        onLostPointerCapture={() => {
          drag.current = null
        }}
      >
        <canvas
          ref={canvas}
          role="img"
          aria-label={PRINTS[index].alt}
          style={{ visibility: ready && !fallback ? "visible" : "hidden" }}
        />
        {(!ready || fallback) && <img className="rg-fallback" src={PRINTS[index].src} alt={PRINTS[index].alt} />}
      </div>
      <footer>
        <div>
          <span>WOODBLOCK STUDIES</span>
          <h3>{PRINTS[index].title}</h3>
        </div>
        <div className="rg-controls">
          <button aria-label="Previous print" onClick={(e) => transition(current.current - 1, e.detail === 0)}>
            ←
          </button>
          <button aria-label="Next print" onClick={(e) => transition(current.current + 1, e.detail === 0)}>
            →
          </button>
        </div>
      </footer>
      <div className="rg-progress" aria-hidden="true">
        <span style={{ transform: `scaleX(${position})` }} />
      </div>
      <style>{`.sf-ribbon-gallery{position:relative;isolation:isolate;width:100%;height:100%;min-height:280px;overflow:hidden;background:#181716;color:#efe7d7;font-family:Arial,sans-serif;container-type:size}.sf-ribbon-gallery *{box-sizing:border-box}.sf-ribbon-gallery header{position:absolute;top:20px;left:22px;right:22px;display:flex;justify-content:space-between;font:9px monospace;letter-spacing:.02em;z-index:1;pointer-events:none}.sf-ribbon-gallery header b{font-weight:400;letter-spacing:.12em}.sf-ribbon-gallery header>span{color:#a39a8e}.sf-ribbon-gallery .rg-art{position:absolute;inset:35px 0 62px;touch-action:pan-y;cursor:grab}.sf-ribbon-gallery .rg-art:active{cursor:grabbing}.sf-ribbon-gallery canvas{width:100%;height:100%;display:block}.sf-ribbon-gallery .rg-fallback{position:absolute;top:9%;left:9%;width:82%;height:82%;object-fit:contain}.sf-ribbon-gallery footer{position:absolute;left:22px;right:17px;bottom:16px;display:flex;align-items:center;justify-content:space-between}.sf-ribbon-gallery footer>div>span{font:7px monospace;letter-spacing:.11em;color:#a79e92}.sf-ribbon-gallery h3{margin:3px 0 0;font:24px Georgia,serif;font-weight:400;letter-spacing:-.03em}.sf-ribbon-gallery .rg-controls{display:flex;gap:6px}.sf-ribbon-gallery button{height:44px;width:44px;border:1px solid #776e624f;border-radius:50%;background:none;color:inherit;font:18px Arial;cursor:pointer;touch-action:manipulation}.sf-ribbon-gallery button:hover{background:#ffffff0b}.sf-ribbon-gallery button:focus-visible{outline:2px solid #e18c4f;outline-offset:3px}.sf-ribbon-gallery .rg-progress{position:absolute;left:0;right:0;bottom:0;height:2px;background:#ffffff0a}.sf-ribbon-gallery .rg-progress>span{display:block;width:100%;height:100%;background:#f19b5d;transform-origin:left}@container(max-width:400px){.sf-ribbon-gallery header{left:16px;right:16px;font-size:8px}.sf-ribbon-gallery footer{left:16px;right:12px}.sf-ribbon-gallery h3{font-size:22px}}`}</style>
    </div>
  )
}

Where it works well

Give a finite art or image transition a dimensional material gesture while preserving a readable resting image.

Reduced motion

Prints change instantly without ribbon deformation. Keyboard navigation is immediate.

Things to know

Native WebGL 1 with a static image fallback if unsupported or context is lost. Supplied art images are CC0.

More examples