Subframe
WebGL & shadersRefraction image carousel

Prism carousel

An architectural photo strip bends through a clear optical edge, then expands a selected image into an undistorted inspection view.

Drag or step through images, then choose Inspect.

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

function useReduced() {
  const [reduced, setReduced] = useState(false)
  useEffect(() => {
    const q = matchMedia("(prefers-reduced-motion: reduce)")
    const sync = () => setReduced(q.matches)
    sync()
    q.addEventListener("change", sync)
    return () => q.removeEventListener("change", sync)
  }, [])
  return reduced
}

const prints = [
  ["/motion/assets/concrete-geometries.webp", "Concrete geometries"],
  ["/motion/assets/concrete-projections.webp", "Cantilever"],
  ["/motion/assets/cathedral-columns.webp", "Stone rhythm"],
]
const prism = `precision highp float;varying vec2 uv;uniform sampler2D atlas;uniform vec2 size;uniform float position;uniform float focus;uniform float chosen;uniform float velocity;
vec3 field(vec2 p){vec3 bg=vec3(.16,.055,.085);float a=size.x/size.y;float x=(p.x-.5)*a+position*.73;float id=floor((x+.365)/.73);float local=mod(x+.365,.73);float shrink=1.-min(.09,abs(velocity)*.008);vec2 q=vec2((local-.035)/.66,(p.y-.10)/.80);q=(q-.5)/shrink+.5;if(q.x<0.||q.x>1.||q.y<0.||q.y>1.)return bg;float image=mod(id+300.,3.);return texture2D(atlas,vec2((image+clamp(q.x,0.002,.998))/3.,clamp(q.y,.002,.998))).rgb;}
void main(){float rim=pow(abs(uv.x-.5)*2.,2.4)*(1.-focus);vec2 p=uv;p.x+=sign(uv.x-.5)*rim*.10;p.y=.5+(uv.y-.5)*(1.+rim*.56);p.y+=sin(uv.y*6.283)*rim*.018;vec2 ca=vec2(.006,.001)*rim;vec3 col=vec3(field(p+ca).r,field(p).g,field(p-ca).b);col+=pow(rim,5.)*.12;vec2 q=vec2((uv.x-.5)*(size.x/size.y)/.768+.5,(uv.y-.5)/.96+.5);vec3 front=vec3(.16,.055,.085);if(q.x>=0.&&q.x<=1.&&q.y>=0.&&q.y<=1.)front=texture2D(atlas,vec2((chosen+clamp(q.x,.002,.998))/3.,clamp(q.y,.002,.998))).rgb;gl_FragColor=vec4(mix(col,front,focus),1.);}`
export default function PrismCarousel({ active = true }: { active?: boolean }) {
  const canvas = useRef<HTMLCanvasElement>(null),
    [index, setIndex] = useState(0),
    [focused, setFocused] = useState(false),
    [failed, setFailed] = useState(false),
    reduced = useReduced(),
    live = useRef({ active, reduced }),
    control = useRef<{
      go: (direction: number, instant: boolean) => void
      focus: (value: boolean, instant: boolean) => void
      stop: () => void
      wake: () => void
    } | null>(null)
  live.current = { active, reduced }
  useEffect(() => {
    const c = canvas.current!,
      gl = c.getContext("webgl", { alpha: false, antialias: false })
    if (!gl) {
      setFailed(true)
      return
    }
    let dead = false,
      loaded = false,
      w = 1,
      h = 1,
      raf = 0,
      last = 0,
      position = 0,
      target = 0,
      speed = 0,
      focus = 0,
      focusTarget = 0,
      chosen = 0
    const shaders: WebGLShader[] = []
    const compile = (type: number, code: string) => {
      const s = gl.createShader(type)!
      gl.shaderSource(s, code)
      gl.compileShader(s)
      shaders.push(s)
      if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw Error("Compile failed")
      return s
    }
    const p = gl.createProgram()!
    let buffer: WebGLBuffer, texture: WebGLTexture
    try {
      gl.attachShader(
        p,
        compile(
          gl.VERTEX_SHADER,
          "attribute vec2 p;varying vec2 uv;void main(){uv=p*.5+.5;gl_Position=vec4(p,0.,1.);}",
        ),
      )
      gl.attachShader(p, compile(gl.FRAGMENT_SHADER, prism))
      gl.linkProgram(p)
      if (!gl.getProgramParameter(p, gl.LINK_STATUS)) throw Error("Link failed")
      gl.useProgram(p)
      buffer = gl.createBuffer()!
      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 a = gl.getAttribLocation(p, "p")
      gl.enableVertexAttribArray(a)
      gl.vertexAttribPointer(a, 2, gl.FLOAT, false, 0, 0)
      texture = gl.createTexture()!
      gl.bindTexture(gl.TEXTURE_2D, texture)
      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)
    } catch {
      gl.deleteProgram(p)
      setFailed(true)
      shaders.forEach((s) => gl.deleteShader(s))
      return
    }
    const get = (n: string) => gl.getUniformLocation(p, n),
      u = {
        size: get("size"),
        position: get("position"),
        focus: get("focus"),
        chosen: get("chosen"),
        velocity: get("velocity"),
      }
    const paint = () => {
      if (!loaded || dead || gl.isContextLost()) return
      gl.viewport(0, 0, c.width, c.height)
      gl.uniform2f(u.size, w, h)
      gl.uniform1f(u.position, position)
      gl.uniform1f(u.focus, focus)
      gl.uniform1f(u.chosen, chosen)
      gl.uniform1f(u.velocity, speed)
      gl.drawArrays(gl.TRIANGLES, 0, 6)
    }
    let dragging: {
      id: number
      x: number
      position: number
      target: number
      moved: boolean
      lastX: number
      lastT: number
    } | null = null
    const tick = (t: number) => {
      raf = 0
      if (!live.current.active || live.current.reduced || document.hidden || gl.isContextLost()) return
      const dt = Math.min(0.032, (t - last) / 1000 || 0.016)
      last = t
      if (!dragging) {
        speed += (target - position) * 155 * dt
        speed *= Math.exp(-22 * dt)
        position += speed * dt
      }
      focus += (focusTarget - focus) * (1 - Math.exp(-dt / 0.11))
      if (Math.abs(focus - focusTarget) < 0.001) focus = focusTarget
      paint()
      if (dragging || Math.abs(target - position) > 0.0003 || Math.abs(speed) > 0.003 || focus !== focusTarget)
        raf = requestAnimationFrame(tick)
    }
    const wake = () => {
      if (!raf && live.current.active && !live.current.reduced && !document.hidden && !gl.isContextLost()) {
        last = 0
        raf = requestAnimationFrame(tick)
      }
    }
    const stop = () => {
      cancelAnimationFrame(raf)
      raf = 0
      last = 0
    }
    const choose = () => {
      chosen = ((Math.round(target) % 3) + 3) % 3
      setIndex(chosen)
    }
    const go = (direction: number, instant: boolean) => {
      target = Math.round(target) + direction
      focusTarget = 0
      setFocused(false)
      choose()
      if (instant || !live.current.active || live.current.reduced) {
        position = target
        speed = 0
        focus = 0
        paint()
      } else wake()
    }
    const setFocus = (v: boolean, instant: boolean) => {
      focusTarget = +v
      setFocused(v)
      if (instant || !live.current.active || live.current.reduced) {
        focus = focusTarget
        paint()
      } else wake()
    }
    const size = () => {
      const r = c.getBoundingClientRect(),
        d = Math.min(devicePixelRatio || 1, 2, Math.sqrt(3600000 / Math.max(1, r.width * r.height)))
      w = r.width
      h = r.height
      const nextWidth = Math.max(1, Math.round(w * d))
      const nextHeight = Math.max(1, Math.round(h * d))
      if (c.width !== nextWidth || c.height !== nextHeight) {
        c.width = nextWidth
        c.height = nextHeight
      }
      paint()
    }
    const down = (e: PointerEvent) => {
      if (e.button !== 0 || focusTarget) return
      dragging = {
        id: e.pointerId,
        x: e.clientX,
        position,
        target,
        moved: false,
        lastX: e.clientX,
        lastT: performance.now(),
      }
      c.setPointerCapture(e.pointerId)
    }
    const move = (e: PointerEvent) => {
      if (!dragging || dragging.id !== e.pointerId) return
      const dx = e.clientX - dragging.x
      if (Math.abs(dx) > 4) dragging.moved = true
      if (!dragging.moved) return
      const now = performance.now()
      const height = c.getBoundingClientRect().height
      speed = ((-(e.clientX - dragging.lastX) / Math.max(10, now - dragging.lastT)) * 1000) / (height * 0.73)
      position = dragging.position - dx / (height * 0.73)
      target = position
      dragging.lastX = e.clientX
      dragging.lastT = now
      paint()
    }
    const up = (e: PointerEvent) => {
      if (!dragging || dragging.id !== e.pointerId) return
      const d = dragging
      dragging = null
      if (e.type === "pointercancel") {
        target = d.target
        position = d.position
        speed = 0
        paint()
        return
      }
      target = Math.round(position + Math.max(-0.45, Math.min(0.45, speed * 0.05)))
      choose()
      if (live.current.reduced || !live.current.active) {
        position = target
        speed = 0
        paint()
      } else wake()
    }
    const sheet = document.createElement("canvas")
    sheet.width = 2400
    sheet.height = 1000
    const ctx = sheet.getContext("2d")!
    let count = 0
    const images = prints.map(([src], i) => {
      const im = new Image()
      im.onload = () => {
        if (dead) return
        const s = Math.max(800 / im.width, 1000 / im.height)
        ctx.save()
        ctx.beginPath()
        ctx.rect(i * 800, 0, 800, 1000)
        ctx.clip()
        ctx.drawImage(im, i * 800 + (800 - im.width * s) / 2, (1000 - im.height * s) / 2, im.width * s, im.height * s)
        ctx.restore()
        count++
        if (count === 3) {
          gl.bindTexture(gl.TEXTURE_2D, texture)
          gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1)
          gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, sheet)
          loaded = true
          size()
        }
      }
      im.onerror = () => {
        if (!dead) setFailed(true)
      }
      im.src = src
      return im
    })
    const ro = new ResizeObserver(size)
    const transforms = new MutationObserver(size)
    for (let node: HTMLElement | null = c; node; node = node.parentElement) {
      ro.observe(node)
      transforms.observe(node, { attributes: true, attributeFilter: ["style", "class"] })
    }
    window.addEventListener("resize", size)
    size()
    c.addEventListener("pointerdown", down)
    c.addEventListener("pointermove", move)
    c.addEventListener("pointerup", up)
    c.addEventListener("pointercancel", up)
    const lost = (e: Event) => {
      e.preventDefault()
      stop()
      setFailed(true)
    }
    c.addEventListener("webglcontextlost", lost)
    const visibility = () => (document.hidden ? stop() : wake())
    document.addEventListener("visibilitychange", visibility)
    control.current = { go, focus: setFocus, wake, stop }
    return () => {
      dead = true
      images.forEach((im) => {
        im.onload = null
        im.onerror = null
      })
      stop()
      ro.disconnect()
      transforms.disconnect()
      window.removeEventListener("resize", size)
      control.current = null
      c.removeEventListener("pointerdown", down)
      c.removeEventListener("pointermove", move)
      c.removeEventListener("pointerup", up)
      c.removeEventListener("pointercancel", up)
      c.removeEventListener("webglcontextlost", lost)
      document.removeEventListener("visibilitychange", visibility)
      gl.deleteTexture(texture)
      gl.deleteBuffer(buffer)
      gl.deleteProgram(p)
      shaders.forEach((s) => gl.deleteShader(s))
      sheet.width = 0
      sheet.height = 0
    }
  }, [])
  useEffect(() => {
    if (active && !reduced) control.current?.wake()
    else control.current?.stop()
  }, [active, reduced])
  const navigate = (direction: number, instant: boolean) => {
    if (!active) return
    if (failed) {
      setIndex((i) => (i + direction + prints.length) % prints.length)
      setFocused(false)
    } else control.current?.go(direction, instant)
  }
  const inspect = (value: boolean, instant: boolean) => {
    if (!active) return
    if (failed) setFocused(value)
    else control.current?.focus(value, instant)
  }
  return (
    <section className="sf-prism-carousel" inert={!active}>
      <header>
        <b>SOLID / OPTIC</b>
        <button type="button" aria-pressed={focused} onClick={(e) => inspect(!focused, e.detail === 0)}>
          {focused ? "Return" : "Inspect"} {focused ? "↙" : "↗"}
        </button>
      </header>
      <div className="pc-stage">
        <img
          className="pc-fallback"
          style={focused ? { inset: "2%", width: "96%", height: "96%" } : undefined}
          src={prints[index][0]}
          alt={prints[index][1]}
        />
        <canvas
          ref={canvas}
          tabIndex={0}
          role="img"
          aria-label={
            "Refracted architectural image carousel. " +
            prints[index][1] +
            ". Use arrow keys to change image, Enter to inspect, Escape to return."
          }
          style={{ visibility: failed ? "hidden" : "visible" }}
          onKeyDown={(e) => {
            if (["ArrowLeft", "ArrowRight", "Enter", "Escape"].includes(e.key)) {
              e.preventDefault()
              if (e.key === "ArrowLeft" || e.key === "ArrowRight") navigate(e.key === "ArrowLeft" ? -1 : 1, true)
              else inspect(e.key === "Enter" ? !focused : false, true)
            }
          }}
        />
      </div>
      <footer>
        <button type="button" aria-label="Previous print" onClick={(e) => navigate(-1, e.detail === 0)}>
          ←
        </button>
        <span>{prints[index][1]}</span>
        <button type="button" aria-label="Next print" onClick={(e) => navigate(1, e.detail === 0)}>
          →
        </button>
      </footer>
      <style>{`.sf-prism-carousel{height:100%;width:100%;display:flex;flex-direction:column;background:#290e16;color:#f2dbe2;font-family:Arial,sans-serif;overflow:hidden}.sf-prism-carousel header{display:flex;justify-content:space-between;align-items:center;padding:9px 20px 0;height:36px;flex-shrink:0}.sf-prism-carousel header b{font-size:10px;letter-spacing:.11em}.sf-prism-carousel button{border:0;background:none;color:#f2dbe2;min-height:44px;font:10px Arial;cursor:pointer}.pc-stage{position:relative;flex:1;min-height:0;overflow:hidden}.pc-stage canvas{position:absolute;inset:0;height:100%;width:100%;display:block;touch-action:pan-y;cursor:grab}.pc-stage canvas:active{cursor:grabbing}.pc-fallback{position:absolute;inset:10%;height:80%;width:80%;object-fit:contain}.sf-prism-carousel footer{height:45px;display:flex;align-items:center;justify-content:center;gap:20px;flex-shrink:0}.sf-prism-carousel footer span{font:italic 17px Georgia;min-width:180px;text-align:center}.sf-prism-carousel footer button{font:20px Arial;width:44px}.sf-prism-carousel button:focus-visible,.sf-prism-carousel canvas:focus-visible{outline:2px solid #e99eb9;outline-offset:-3px}`}</style>
    </section>
  )
}

Where it works well

Give a compact visual collection a material transition while preserving a clear enlarged image.

Reduced motion

Stepping and inspection settle immediately; the stationary optical treatment remains.

Things to know

Native WebGL 1 and three local images. The lens is an artistic UV refraction, not a physically traced glass object; a flat image is the fallback.

More examples