# Three-Sided Billboard Photo Gallery with Prism Slats That Rotate in a Wave Between Pictures

> Gallery built like a three-sided roadside billboard in WebGL: prism slats rotate in a wave to change between photographs. Free React and HTML code to copy.

A shadcn/ui block from the [@weinc](https://we.inc/components?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/weinc/three-sided-billboard-photo-gallery-with-prism-slats-that-rotate-in-a-wave-between-pictures).

- Page, with a live preview: https://shadcn.exchange/r/weinc/three-sided-billboard-photo-gallery-with-prism-slats-that-rotate-in-a-wave-between-pictures
- Screenshot: https://shadcn.exchange/shot/weinc/three-sided-billboard-photo-gallery-with-prism-slats-that-rotate-in-a-wave-between-pictures/light.png
- Registry item: https://we.inc/r/three-sided-billboard-photo-gallery-with-prism-slats-that-rotate-in-a-wave-between-pictures.json
- Listed: 2026-10-28, source last changed 2026-10-28

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/weinc/three-sided-billboard-photo-gallery-with-prism-slats-that-rotate-in-a-wave-between-pictures.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `framer-motion`

## Files

### `components/we-inc/three-sided-billboard-photo-gallery-with-prism-slats-that-rotate-in-a-wave-between-pictures.tsx`

```tsx
import { useEffect, useRef, useState } from "react";
import type { KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent } from "react";
import { useReducedMotion } from "framer-motion";

// One picture per side of the prisms. Exactly three, framed alike.
const sides = [
  {
    name: "Vermilion",
    src: "https://images.pexels.com/photos/15689960/pexels-photo-15689960.jpeg?auto=compress&cs=tinysrgb&w=900",
    alt: "Close portrait of a man with a tall afro in a white striped shirt against a red wall",
    tone: "#b5372a",
  },
  {
    name: "Saffron",
    src: "https://images.pexels.com/photos/6976854/pexels-photo-6976854.jpeg?auto=compress&cs=tinysrgb&w=900",
    alt: "A woman in a mustard beret and mustard jumper leaning on a tan suitcase against a yellow wall",
    tone: "#e2a12c",
  },
  {
    name: "Cobalt",
    src: "https://images.pexels.com/photos/35707519/pexels-photo-35707519.jpeg?auto=compress&cs=tinysrgb&w=900",
    alt: "A woman with an afro and sunglasses crouching in a pink dress and black boots against a bright blue wall",
    tone: "#1f7fd6",
  },
];

/** How many prisms the picture is cut into. Up to 20. */
const SLATS = 14;
/** Seconds between one prism starting to turn and its neighbour following. */
const STAGGER = 0.052;
/** Seconds the billboard rests on a picture before it turns by itself. */
const REST = 3.5;

const VERT = `attribute vec2 a; void main(){ gl_Position = vec4(a, 0.0, 1.0); }`;

const FRAG = `precision highp float;
uniform vec2 u_res;
uniform sampler2D u_t0;
uniform sampler2D u_t1;
uniform sampler2D u_t2;
uniform vec3 u_img;
uniform float u_ang[20];
uniform float u_n;

vec2 cover(vec2 uv, float img){
  float A = u_res.x / u_res.y;
  vec2 t = uv;
  if (img > A) t.x = (uv.x - 0.5) * A / img + 0.5;
  else t.y = (uv.y - 0.5) * img / A + 0.5;
  return clamp(t, 0.0, 1.0);
}
float hash(float n){ return fract(sin(n * 91.345) * 47453.5453); }

void main(){
  vec2 uv = vec2(gl_FragCoord.x / u_res.x, 1.0 - gl_FragCoord.y / u_res.y);
  float x = uv.x * u_n;
  float i = floor(x);
  float lx = fract(x) - 0.5;
  float ang = 0.0;
  for (int k = 0; k < 20; k++) { if (float(k) == i) ang = u_ang[k]; }

  // Each prism is an equilateral triangle seen from the front, a little narrower than its bay.
  float side = 0.972;
  float R = side / 1.7320508;
  float pxw = u_n / u_res.x;
  // The dark of the housing seen between prisms, with a little light leaking in from the front.
  vec3 col = vec3(0.016, 0.017, 0.020) + vec3(0.02) * smoothstep(0.42, 0.5, abs(lx));

  vec2 lamp = normalize(vec2(-0.42, 0.91));
  vec2 half_ = normalize(lamp + vec2(0.0, 1.0));
  for (int f = 0; f < 3; f++) {
    float n = float(f) * 2.0943951 - ang;
    float c = cos(n);
    if (c <= 0.0) continue;
    float x1 = R * sin(n - 1.0471976);
    float x2 = R * sin(n + 1.0471976);
    float e = min(lx - x1, x2 - lx);
    float inside = clamp(e / pxw + 0.5, 0.0, 1.0);
    if (inside <= 0.0) continue;
    float w = clamp((lx - x1) / (x2 - x1), 0.0, 1.0);
    vec2 at = vec2((i + w) / u_n, uv.y);
    vec3 pic;
    if (f == 0) pic = texture2D(u_t0, cover(at, u_img.x)).rgb;
    else if (f == 1) pic = texture2D(u_t1, cover(at, u_img.y)).rgb;
    else pic = texture2D(u_t2, cover(at, u_img.z)).rgb;

    vec2 nor = vec2(sin(n), c);
    float diffuse = max(dot(nor, lamp), 0.0);
    // How far back this point of the face sits. Faces turned into the housing fall into shade.
    float z = R * mix(cos(n - 1.0471976), cos(n + 1.0471976), w);
    float depth = smoothstep(-0.45 * R, 0.5 * R, z);
    vec3 lit = pic * (0.10 + 0.99 * diffuse) * mix(0.42, 1.0, depth);
    // The printed vinyl is glossy: a band of the lamp slides across a face as it swings past.
    float gloss = pow(max(dot(nor, half_), 0.0), 130.0);
    lit += vec3(1.0, 0.98, 0.94) * gloss * 0.30 * (0.4 + 0.6 * depth);
    // Folded aluminium lip down each long edge of a face.
    float lip = 1.0 - smoothstep(0.0, 0.022 / max(c, 0.25), min(w, 1.0 - w));
    vec3 metal = vec3(0.62, 0.64, 0.67) * (0.25 + 0.95 * diffuse) + vec3(1.0) * gloss * 0.6;
    lit = mix(lit, metal, lip * 0.45);
    col = mix(col, lit, inside);
  }

  // Lamps above the board: brighter at the top, and the rails shade the very ends of the prisms.
  col *= 1.07 - 0.20 * uv.y;
  col *= 0.55 + 0.45 * smoothstep(0.0, 0.035, uv.y);
  col *= 0.70 + 0.30 * smoothstep(0.0, 0.022, 1.0 - uv.y);
  // No two prisms are hung perfectly true, so each takes the light a touch differently.
  col *= 0.975 + 0.05 * hash(i + 3.0);
  gl_FragColor = vec4(col, 1.0);
}`;

export default function ThreeSidedBillboardPhotoGalleryWithPrismSlatsThatRotateInAWaveBetweenPictures() {
  const reduce = useReducedMotion();
  const face = useRef<HTMLButtonElement>(null);
  const canvas = useRef<HTMLCanvasElement>(null);
  const pics = useRef<(HTMLImageElement | null)[]>([]);
  /** Requests from the page to the draw loop: turns to make, and where the pointer is over the board. */
  const ask = useRef<{ turns: { by: number; from: number }[]; x: number; at: number; touched: number }>({ turns: [], x: 0.5, at: -1e6, touched: -1e6 });
  const [live, setLive] = useState(false);
  const [shown, setShown] = useState(0);

  useEffect(() => {
    const el = face.current;
    const cv = canvas.current;
    const imgs = pics.current;
    if (reduce || !el || !cv || imgs.length < 3 || imgs.some((img) => !img)) return;
    const gl = cv.getContext("webgl", { antialias: false, alpha: false });
    if (!gl) return;

    const compile = (type: number, source: string) => {
      const shader = gl.createShader(type)!;
      gl.shaderSource(shader, source);
      gl.compileShader(shader);
      return shader;
    };
    const program = gl.createProgram()!;
    gl.attachShader(program, compile(gl.VERTEX_SHADER, VERT));
    gl.attachShader(program, compile(gl.FRAGMENT_SHADER, FRAG));
    gl.linkProgram(program);
    if (!gl.getProgramParameter(program, gl.LINK_STATUS)) return;
    gl.useProgram(program);
    gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW);
    const at = gl.getAttribLocation(program, "a");
    gl.enableVertexAttribArray(at);
    gl.vertexAttribPointer(at, 2, gl.FLOAT, false, 0, 0);
    const u = (name: string) => gl.getUniformLocation(program, name);
    gl.uniform1i(u("u_t0"), 0);
    gl.uniform1i(u("u_t1"), 1);
    gl.uniform1i(u("u_t2"), 2);
    gl.uniform1f(u("u_n"), SLATS);
    const uni = { res: u("u_res"), img: u("u_img"), ang: u("u_ang[0]") };

    const third = (Math.PI * 2) / 3;
    const angle = new Float32Array(20);
    const speed = new Float32Array(20);
    const goal = new Float32Array(20);
    const lean = new Float32Array(20);
    /** Each prism hangs a hair off true. */
    const off = Array.from({ length: 20 }, (_, i) => Math.sin(i * 12.9898) * 0.022);
    const queue: { col: number; when: number; to: number }[] = [];
    let step = 0;
    let ready = false;
    let visible = true;
    let raf = 0;
    let last = 0;
    let clock = 0;
    let nextTurn = 0.9;

    const upload = () => {
      if (ready || imgs.some((img) => !img || !img.complete || !img.naturalWidth)) return;
      try {
        imgs.forEach((img, unit) => {
          gl.activeTexture(gl.TEXTURE0 + unit);
          gl.bindTexture(gl.TEXTURE_2D, gl.createTexture());
          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.RGB, gl.RGB, gl.UNSIGNED_BYTE, img!);
        });
        const ratio = (img: HTMLImageElement | null) => (img ? img.naturalWidth / img.naturalHeight : 1);
        gl.uniform3f(uni.img, ratio(imgs[0]), ratio(imgs[1]), ratio(imgs[2]));
        ready = true;
        setLive(true);
      } catch {
        // A picture could not be read into the shader: the still version underneath stays on show.
        ready = false;
      }
    };

    const resize = () => {
      const ratio = Math.min(window.devicePixelRatio || 1, 2);
      cv.width = Math.max(1, Math.round(el.clientWidth * ratio));
      cv.height = Math.max(1, Math.round(el.clientHeight * ratio));
      gl.viewport(0, 0, cv.width, cv.height);
      gl.uniform2f(uni.res, cv.width, cv.height);
    };

    const loop = (stamp: number) => {
      if (ready) {
        const dt = Math.min(last ? (stamp - last) / 1000 : 0.016, 0.033);
        last = stamp;
        clock += dt;
        const a = ask.current;
        // With nobody near it, the board turns to its next picture on a timer, starting from the left.
        if (clock >= nextTurn && stamp - a.touched > 4200) a.turns.push({ by: 1, from: 0 });
        while (a.turns.length) {
          const turn = a.turns.shift()!;
          step += turn.by;
          for (let i = 0; i < SLATS; i++) queue.push({ col: i, when: clock + Math.abs(i - turn.from) * STAGGER, to: step });
          nextTurn = clock + REST + SLATS * STAGGER;
          setShown(((step % 3) + 3) % 3);
        }
        for (let q = queue.length - 1; q >= 0; q--) {
          if (queue[q].when <= clock) {
            goal[queue[q].col] = queue[q].to;
            queue.splice(q, 1);
          }
        }
        const near = stamp - a.at < 900;
        for (let i = 0; i < SLATS; i++) {
          // A hand passing over the board nudges the prisms beneath it part of the way round.
          const away = a.x * SLATS - (i + 0.5);
          const want = near ? 0.5 * Math.exp(-(away * away) / 3.2) : 0;
          lean[i] += (want - lean[i]) * Math.min(1, dt * 9);
          const target = goal[i] * third + off[i] + lean[i];
          // A firm spring, slightly under-damped, so each prism overshoots and clacks back onto its stop.
          speed[i] += ((target - angle[i]) * 95 - speed[i] * 9.6) * dt;
          angle[i] += speed[i] * dt;
        }
        gl.uniform1fv(uni.ang, angle);
        gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
      }
      raf = visible && !document.hidden ? requestAnimationFrame(loop) : 0;
    };
    const wake = () => {
      if (!raf && visible && !document.hidden) {
        last = 0;
        raf = requestAnimationFrame(loop);
      }
    };

    const ro = new ResizeObserver(resize);
    ro.observe(el);
    const io = new IntersectionObserver(([entry]) => {
      visible = entry.isIntersecting;
      wake();
    });
    io.observe(el);
    document.addEventListener("visibilitychange", wake);
    imgs.forEach((img) => img?.addEventListener("load", upload));
    upload();
    resize();
    wake();

    return () => {
      cancelAnimationFrame(raf);
      ro.disconnect();
      io.disconnect();
      document.removeEventListener("visibilitychange", wake);
      imgs.forEach((img) => img?.removeEventListener("load", upload));
    };
  }, [reduce]);

  /** Turn the board by a number of sides, the wave starting at a given prism. Without the canvas the pictures simply swap. */
  function turn(by: number, from: number) {
    if (live) ask.current.turns.push({ by, from });
    else setShown((n) => (((n + by) % 3) + 3) % 3);
    ask.current.touched = performance.now();
  }
  function over(e: ReactPointerEvent<HTMLButtonElement>) {
    const r = e.currentTarget.getBoundingClientRect();
    ask.current.x = (e.clientX - r.left) / r.width;
    ask.current.at = performance.now();
    ask.current.touched = ask.current.at;
  }
  function onKey(e: ReactKeyboardEvent<HTMLButtonElement>) {
    if (e.key === "ArrowRight") {
      e.preventDefault();
      turn(1, 0);
    } else if (e.key === "ArrowLeft") {
      e.preventDefault();
      turn(-1, SLATS - 1);
    }
  }

  return (
    <section className="relative isolate flex min-h-[800px] w-full flex-col items-center justify-center overflow-hidden bg-[#0d0e12] px-4 py-14">
      <style>{`
        .tsb-seams { background-image: linear-gradient(90deg, rgba(0,0,0,0) calc(100% - 2px), rgba(0,0,0,0.7) calc(100% - 2px)); background-size: calc(100% / ${SLATS}) 100%; }
        .tsb-case { background: linear-gradient(100deg, #9a9ea6 0%, #d5d8dd 16%, #b3b7be 34%, #e3e5e9 52%, #a4a8b0 70%, #c9ccd2 86%, #8b8f97 100%); }
        .tsb-well { box-shadow: 0 0 0 1px rgba(0,0,0,0.75), 0 1px 0 1px rgba(255,255,255,0.5), inset 0 0 0 1px #000; }
        .tsb-rail { background: linear-gradient(180deg, #4a4d55 0%, #2a2c32 18%, #1b1c21 55%, #101114 100%); }
      `}</style>
      <div aria-hidden="true" className="absolute inset-0 -z-10" style={{ background: "radial-gradient(55% 45% at 50% 30%, #23262f 0%, #0d0e12 72%)" }} />

      {/* The housing: a top rail, the turning face and a bottom rail. */}
      <div className="tsb-case relative w-[min(86vw,430px)] rounded-[7px] p-[12px] shadow-[inset_0_1px_0_rgba(255,255,255,0.85),inset_0_-1px_0_rgba(0,0,0,0.45),inset_1px_0_0_rgba(255,255,255,0.35),inset_-1px_0_0_rgba(0,0,0,0.25),0_1px_2px_rgba(0,0,0,0.8),0_50px_80px_-30px_rgba(0,0,0,0.95)]">
        <div className="tsb-well rounded-[2px] bg-[#08080a]">
        <div aria-hidden="true" className="tsb-rail flex h-[18px] items-center justify-between rounded-t-[2px] px-2 shadow-[inset_0_1px_0_rgba(255,255,255,0.22),0_3px_5px_rgba(0,0,0,0.6)]">
          <span className="h-[5px] w-[5px] rounded-full bg-[#0b0b0d] shadow-[0_1px_0_rgba(255,255,255,0.18)]" />
          <span className="h-[5px] w-[5px] rounded-full bg-[#0b0b0d] shadow-[0_1px_0_rgba(255,255,255,0.18)]" />
        </div>
        <button
          ref={face}
          type="button"
          aria-label={`Billboard showing ${sides[shown].name}, picture ${shown + 1} of 3. Press to turn to the next picture. Left and right arrows turn it either way.`}
          onClick={(e) => {
            const r = e.currentTarget.getBoundingClientRect();
            turn(1, e.detail === 0 ? 0 : Math.min(SLATS - 1, Math.max(0, Math.floor(((e.clientX - r.left) / r.width) * SLATS))));
          }}
          onPointerMove={over}
          onKeyDown={onKey}
          className="relative block aspect-[4/5] w-full cursor-pointer touch-pan-y overflow-hidden bg-[#08080a] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-300 focus-visible:ring-inset"
        >
          {sides.map((side, i) => (
            <img
              key={side.name}
              src={side.src}
              alt={side.alt}
              ref={(node) => {
                pics.current[i] = node;
              }}
              crossOrigin="anonymous"
              width={900}
              height={1125}
              style={{ backgroundColor: side.tone }}
              className={`absolute inset-0 h-full w-full object-cover ${i === shown ? "opacity-100" : "opacity-0"}`}
            />
          ))}
          <span aria-hidden="true" className="tsb-seams absolute inset-0 block" />
          <canvas ref={canvas} aria-hidden="true" className={`absolute inset-0 h-full w-full transition-opacity duration-500 ${live ? "opacity-100" : "opacity-0"}`} />
        </button>
        <div aria-hidden="true" className="tsb-rail h-[14px] rounded-b-[2px] shadow-[inset_0_1px_0_rgba(255,255,255,0.16)]" />
        </div>
      </div>

      <div className="mt-6 flex w-[min(86vw,430px)] items-center justify-between gap-3">
        <div className="flex gap-1.5" role="group" aria-label="Choose a picture">
          {sides.map((side, i) => (
            <button
              key={side.name}
              type="button"
              aria-pressed={i === shown}
              onClick={() => {
                const by = (((i - shown) % 3) + 3) % 3;
                if (by) turn(by === 2 ? -1 : 1, by === 2 ? SLATS - 1 : 0);
              }}
              className={`flex h-10 items-center gap-2 rounded-full px-3 text-[13px] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-300 ${i === shown ? "bg-white/10 text-white" : "text-zinc-400 hover:text-white"}`}
            >
              <span aria-hidden="true" className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: side.tone }} />
              {side.name}
            </button>
          ))}
        </div>
        <p className="hidden whitespace-nowrap text-[13px] text-zinc-400 sm:block">Click to turn</p>
      </div>
    </section>
  );
}
```
