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

A roadside sign of the kind that shows three adverts in turn, shrunk to a card. The picture is cut into fourteen upright prisms held between two aluminium rails. Each prism carries a strip of all three photographs, one per side, and to change picture they turn a third of the way round, each starting a moment after its neighbour, so a ripple crosses the board and the next photograph assembles itself strip by strip. Every prism is on a spring that overshoots and knocks back onto its stop. While a face is edge-on it falls into shadow, its metal lip shows, and a band of lamp light slides over the glossy print. Running the pointer over the board nudges the prisms beneath it part of the way round. A click turns the board starting from the prism you clicked, and three named pills below jump to a picture. On phones the board fits the screen width and a tap turns it.

Code

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>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/three-sided-billboard-photo-gallery-with-prism-slats-that-rotate-in-a-wave-between-pictures.json

When to use it

How to customize it

Accessibility

Questions

How are the prisms drawn without a 3D library?

One fragment shader does it in flat arithmetic. For each pixel it works out which prism it falls in and that prism's angle, then tests the three faces. A face turned towards the viewer covers a span of the prism's width that follows from its angle, and where the pixel lands inside that span says which part of the photograph to show. The same angle gives the shading.

Can it show more than three images?

Not on one board, because the mechanism is a triangle. For a longer set, swap a new photograph into whichever face is at the back after each turn. The textures are bound as `u_t0`, `u_t1` and `u_t2`, and the face hidden from view is the one two steps behind `step`.

Why do the prisms overshoot?

Each angle is driven by a spring that is a little under-damped, so it swings a few degrees past the stop and settles. Real signs of this kind land with a clack, and without the overshoot the turn reads as a slide effect.

Does the pointer change the picture or only disturb it?

Moving over the board only leans the nearest prisms by up to about thirty degrees, falling off over three or four slats, and they spring back when the pointer leaves. Clicking is what commits a turn.

Is the uneven brightness between slats a bug?

It is deliberate. Each prism hangs a fraction of a degree off true and takes the light slightly differently, as the panels of a real sign do. Set the `off` array to zeros and remove the last `hash` line in the shader for a perfectly even face.

Related: Photo Proof Roll Carousel That Feeds over a Roller and Curls onto the Table, Glossy Photo Print That Peels Off a Second Photograph with a Lit Curling Underside.

More galleries and carousels or all free UI components. Start for free to have a full site built around it.

More in Galleries

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap