Concertina Fold Photo Book Carousel That Opens Flat at the Centre

A leporello, the folded strip of card that photographers use for small editions, standing on a pale table. Nine portraits are mounted one per panel with a typed title and plate number under each. Away from the middle the panels zigzag steeply, so you see slivers of colour, shaded on the faces turned from the light and blurred slightly with distance. Whichever panel reaches the middle unfolds until it faces you square on. Pull the strip sideways and the pleats travel through the book; pull fast and the whole thing stretches flatter before it settles back. It unfolds from a closed pack on load, then advances by itself. On phones the panels are half the screen wide and the strip runs off both edges.

Code

import { useCallback, useEffect, useRef, useState } from "react";
import { useReducedMotion } from "framer-motion";

/** The prints, in the order they are bound into the book. */
const prints = [
  { title: "Cobalt on ochre", src: "https://images.pexels.com/photos/14792999/pexels-photo-14792999.jpeg?auto=compress&cs=tinysrgb&w=700", alt: "Woman in a blue blazer lit with blue light against a deep yellow backdrop", focus: "50% 30%" },
  { title: "White shirt, red room", src: "https://images.pexels.com/photos/2734952/pexels-photo-2734952.jpeg?auto=compress&cs=tinysrgb&w=700", alt: "Person with cropped orange hair and hoop earrings in a white shirt against a red backdrop", focus: "55% 25%" },
  { title: "Tangerine halter", src: "https://images.pexels.com/photos/39325164/pexels-photo-39325164.jpeg?auto=compress&cs=tinysrgb&w=700", alt: "Woman in an orange halter top and lime skirt seated against a vivid orange backdrop", focus: "50% 30%" },
  { title: "Green sleeve", src: "https://images.pexels.com/photos/21352926/pexels-photo-21352926.jpeg?auto=compress&cs=tinysrgb&w=700", alt: "Woman in a green top with a dark jacket slipping off her shoulders against a crimson backdrop", focus: "50% 30%" },
  { title: "Lace gloves", src: "https://images.pexels.com/photos/18763472/pexels-photo-18763472.jpeg?auto=compress&cs=tinysrgb&w=700", alt: "Model in an orange jacket covering her eyes with sheer dotted gloves", focus: "50% 35%" },
  { title: "Check coat", src: "https://images.pexels.com/photos/36440745/pexels-photo-36440745.jpeg?auto=compress&cs=tinysrgb&w=700", alt: "Man in a grey check coat resting a hand on his temple against a teal backdrop", focus: "50% 25%" },
  { title: "Velvet on magenta", src: "https://images.pexels.com/photos/14938882/pexels-photo-14938882.jpeg?auto=compress&cs=tinysrgb&w=700", alt: "Woman in a black velvet dress reclining with one arm raised against a magenta backdrop", focus: "55% 30%" },
  { title: "Pink crop, red wash", src: "https://images.pexels.com/photos/12089098/pexels-photo-12089098.jpeg?auto=compress&cs=tinysrgb&w=700", alt: "Woman in a pink crop top and dark trousers lit red against a coral backdrop", focus: "50% 30%" },
  { title: "Blue hour curls", src: "https://images.pexels.com/photos/15817799/pexels-photo-15817799.jpeg?auto=compress&cs=tinysrgb&w=700", alt: "Woman with curly hair in a loose sweater under deep blue studio light", focus: "50% 25%" },
];

const N = prints.length;
const SLOTS = N * 2; // panels kept in the page; must be even so the zigzag never breaks
const FOLD = 64; // degrees each panel turns away once it is clear of the centre
const WRAP = 9; // degrees of gentle curve that carries both ends back into the distance
const HOLD_MS = 2600; // pause on each print while it plays by itself

const mod = (a: number, n: number) => ((a % n) + n) % n;
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
const smooth = (a: number, b: number, v: number) => {
  const t = clamp((v - a) / (b - a), 0, 1);
  return t * t * (3 - 2 * t);
};

export default function ConcertinaFoldPhotoBook() {
  const reduce = useReducedMotion();
  const stageRef = useRef<HTMLDivElement>(null);
  const panelRefs = useRef<(HTMLDivElement | null)[]>([]);
  const faceRefs = useRef<(HTMLDivElement | null)[]>([]);
  const shadeRefs = useRef<(HTMLDivElement | null)[]>([]);
  const floorRef = useRef<HTMLDivElement>(null);
  const [active, setActive] = useState(0);
  const sim = useRef({
    pos: -1.4,
    vel: 0,
    target: 0,
    open: 0,
    openVel: 0,
    w: 280,
    dragging: false,
    moved: 0,
    lastX: 0,
    lastT: 0,
    paused: false,
    nextAuto: 0,
    blur: new Array<number>(SLOTS).fill(-1),
    active: 0,
  });

  const step = useCallback((dir: number) => {
    const s = sim.current;
    s.target = Math.round(s.target) + dir;
    s.nextAuto = performance.now() + HOLD_MS * 2;
  }, []);

  useEffect(() => {
    const stage = stageRef.current;
    if (!stage) return;
    const s = sim.current;
    let raf = 0;
    let visible = true;
    let last = performance.now();
    s.nextAuto = last + 1900;
    if (reduce) {
      s.pos = 0;
      s.open = 1;
    }

    const measure = () => {
      const sw = stage.clientWidth;
      s.w = sw < 640 ? Math.round(sw * 0.54) : Math.min(320, Math.round(sw * 0.27));
      stage.style.setProperty("--w", `${s.w}px`);
    };
    measure();
    const ro = new ResizeObserver(measure);
    ro.observe(stage);

    const layout = (now: number) => {
      const w = s.w;
      const idle = reduce ? 0 : Math.sin(now / 1100) * 1.6;
      const stretch = clamp(Math.abs(s.vel) * 0.07, 0, 0.3);
      const fold = (88 + (FOLD - 88) * s.open) * (1 - stretch) + idle;
      const base = Math.round(s.pos);
      const k0 = base - SLOTS / 2;
      const hx: number[] = [0];
      const hz: number[] = [0];
      const th: number[] = [];
      for (let j = 0; j < SLOTS; j++) {
        const k = k0 + j;
        const d = k - s.pos;
        const f = smooth(0.28, 1.2, Math.abs(d));
        // The zigzag, plus a slight wrap so both ends curve away from the viewer.
        const a = (mod(k, 2) === 0 ? 1 : -1) * fold * f + WRAP * Math.tanh(d * 0.9);
        th.push(a);
        const r = (a * Math.PI) / 180;
        hx.push(hx[j] + w * Math.cos(r));
        hz.push(hz[j] - w * Math.sin(r));
      }
      // The point of the strip that should sit dead centre.
      const cj = base - k0;
      const fr = s.pos + 0.5 - base;
      const cr = (th[cj] * Math.PI) / 180;
      const cx = hx[cj] + fr * w * Math.cos(cr);
      // Sit the book back a little so the ridge that folds toward you never outgrows the open panel.
      const cz = hz[cj] - fr * w * Math.sin(cr) + w * 0.4 * Math.sin((fold * Math.PI) / 180);
      for (let j = 0; j < SLOTS; j++) {
        const k = k0 + j;
        const slot = mod(k, SLOTS);
        const el = panelRefs.current[slot];
        if (!el) continue;
        const d = Math.abs(k - s.pos);
        el.style.transform = `translate3d(${(hx[j] - cx).toFixed(2)}px,0,${(hz[j] - cz).toFixed(2)}px) rotateY(${th[j].toFixed(2)}deg)`;
        el.style.opacity = clamp(SLOTS / 2 - 0.4 - d, 0, 1).toFixed(2);
        el.dataset.k = String(k);
        const shade = shadeRefs.current[slot];
        if (shade) shade.style.opacity = Math.abs(Math.sin((th[j] * Math.PI) / 180)).toFixed(3);
        const depth = Math.abs((hz[j] + hz[j + 1]) / 2 - cz + w * 0.4);
        const b = Math.round(clamp((depth - w * 0.55) / (w * 0.4) + (d - 3) * 0.6, 0, 3.5) * 2) / 2;
        if (b !== s.blur[slot]) {
          s.blur[slot] = b;
          const face = faceRefs.current[slot];
          if (face) face.style.filter = b ? `blur(${b}px)` : "none";
        }
      }
      if (floorRef.current) {
        const span = hx[SLOTS] - hx[0];
        floorRef.current.style.width = `${Math.min(span, stage.clientWidth * 1.1).toFixed(0)}px`;
      }
      const a = mod(Math.round(s.pos), N);
      if (a !== s.active) {
        s.active = a;
        setActive(a);
      }
    };

    const tick = (now: number) => {
      const dt = Math.min(0.033, (now - last) / 1000);
      last = now;
      if (reduce) {
        s.pos = s.target;
        s.vel = 0;
        s.open = 1;
      } else {
        if (!s.dragging) {
          const acc = 62 * (s.target - s.pos) - 11.5 * s.vel;
          s.vel += acc * dt;
          s.pos += s.vel * dt;
          if (!s.paused && now > s.nextAuto) {
            s.target = Math.round(s.target) + 1;
            s.nextAuto = now + HOLD_MS;
          }
        }
        const oa = 38 * (1 - s.open) - 9 * s.openVel;
        s.openVel += oa * dt;
        s.open += s.openVel * dt;
      }
      layout(now);
      if (visible) raf = requestAnimationFrame(tick);
    };

    const io = new IntersectionObserver(([e]) => {
      const was = visible;
      visible = e.isIntersecting;
      if (visible && !was) {
        last = performance.now();
        raf = requestAnimationFrame(tick);
      }
    });
    io.observe(stage);
    raf = requestAnimationFrame(tick);

    const down = (e: PointerEvent) => {
      if (e.button !== 0) return;
      s.dragging = true;
      s.moved = 0;
      s.lastX = e.clientX;
      s.lastT = performance.now();
      s.vel = 0;
      stage.setPointerCapture(e.pointerId);
    };
    const move = (e: PointerEvent) => {
      if (!s.dragging) return;
      const now = performance.now();
      const dx = e.clientX - s.lastX;
      const dp = -dx / (s.w * 0.78);
      s.pos += dp;
      s.moved += Math.abs(dx);
      const dt = Math.max(1, now - s.lastT) / 1000;
      s.vel = s.vel * 0.6 + (dp / dt) * 0.4;
      s.lastX = e.clientX;
      s.lastT = now;
    };
    const up = (e: PointerEvent) => {
      if (!s.dragging) return;
      s.dragging = false;
      if (s.moved < 6) {
        // A plain tap: bring the tapped panel to the centre.
        const hit = (document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null)?.closest<HTMLElement>("[data-k]");
        if (hit?.dataset.k) s.target = Number(hit.dataset.k);
      } else {
        s.target = Math.round(s.pos + clamp(s.vel, -9, 9) * 0.22);
      }
      s.nextAuto = performance.now() + HOLD_MS * 2;
    };
    stage.addEventListener("pointerdown", down);
    stage.addEventListener("pointermove", move);
    stage.addEventListener("pointerup", up);
    stage.addEventListener("pointercancel", up);

    return () => {
      cancelAnimationFrame(raf);
      ro.disconnect();
      io.disconnect();
      stage.removeEventListener("pointerdown", down);
      stage.removeEventListener("pointermove", move);
      stage.removeEventListener("pointerup", up);
      stage.removeEventListener("pointercancel", up);
    };
  }, [reduce]);

  const onKey = (e: React.KeyboardEvent) => {
    if (e.key === "ArrowRight") {
      e.preventDefault();
      step(1);
    } else if (e.key === "ArrowLeft") {
      e.preventDefault();
      step(-1);
    }
  };

  const pause = (v: boolean) => () => {
    sim.current.paused = v;
    if (!v) sim.current.nextAuto = performance.now() + HOLD_MS;
  };

  return (
    <section className="relative overflow-hidden bg-[#e7e5df] py-10 sm:py-14" onMouseEnter={pause(true)} onMouseLeave={pause(false)} onFocus={pause(true)} onBlur={pause(false)}>
      <div aria-hidden="true" className="pointer-events-none absolute inset-0" style={{ background: "radial-gradient(120% 70% at 50% 38%, #f6f5f1 0%, #e9e7e1 48%, #d3d0c8 100%)" }} />
      <div
        ref={stageRef}
        role="group"
        aria-roledescription="carousel"
        aria-label="Concertina photo book. Use the left and right arrow keys to pull it through."
        tabIndex={0}
        onKeyDown={onKey}
        className="relative mx-auto w-full max-w-6xl cursor-grab touch-pan-y select-none outline-none focus-visible:ring-2 focus-visible:ring-stone-900 focus-visible:ring-offset-4 focus-visible:ring-offset-[#e7e5df] active:cursor-grabbing"
        style={{ height: "calc(var(--w, 280px) * 1.44 + 72px)", perspective: "1900px", perspectiveOrigin: "50% 4%" }}
      >
        <div className="absolute left-1/2 top-6" style={{ transformStyle: "preserve-3d", width: 0, height: "calc(var(--w, 280px) * 1.44)" }}>
          {/* One soft pool of shadow under the whole book */}
          <div
            ref={floorRef}
            aria-hidden="true"
            className="absolute bottom-0 left-0 h-40"
            style={{
              transform: "translateX(-50%) translateY(50%) rotateX(90deg)",
              background: "radial-gradient(closest-side, rgba(40,32,20,0.2), rgba(40,32,20,0.08) 55%, rgba(40,32,20,0) 100%)",
              filter: "blur(10px)",
            }}
          />
          {Array.from({ length: SLOTS }, (_, slot) => {
            const p = prints[slot % N];
            const away = slot % 2 === 0;
            return (
              <div
                key={slot}
                ref={(el) => {
                  panelRefs.current[slot] = el;
                }}
                className="absolute left-0 top-0 h-full"
                style={{ width: "var(--w, 280px)", transformOrigin: "0 50%", transformStyle: "preserve-3d", willChange: "transform" }}
              >
                <div
                  ref={(el) => {
                    faceRefs.current[slot] = el;
                  }}
                  className="absolute inset-0 overflow-hidden rounded-[3px] bg-[#f4f1ea]"
                  style={{ boxShadow: "inset 0 0 0 1px rgba(60,48,30,0.10)", backfaceVisibility: "hidden" }}
                >
                  <div className="absolute inset-[5%] bottom-[13%] overflow-hidden bg-stone-300">
                    <img src={p.src} alt={slot < N ? p.alt : ""} loading="lazy" draggable={false} className="h-full w-full object-cover" style={{ objectPosition: p.focus }} />
                  </div>
                  <div className="absolute inset-x-[6%] bottom-[4.2%] flex items-baseline justify-between font-mono text-[9px] uppercase tracking-[0.14em] text-stone-600 sm:text-[10px]">
                    <span>{p.title}</span>
                    <span>{String((slot % N) + 1).padStart(2, "0")}</span>
                  </div>
                  {/* Light falling across the fold: bright on the ridge, dark in the valley */}
                  <div
                    ref={(el) => {
                      shadeRefs.current[slot] = el;
                    }}
                    aria-hidden="true"
                    className="absolute inset-0"
                    style={{
                      background: away
                        ? "linear-gradient(90deg, rgba(255,255,255,0.10) 0%, rgba(28,20,10,0.30) 30%, rgba(28,20,10,0.72) 100%)"
                        : "linear-gradient(90deg, rgba(28,20,10,0.50) 0%, rgba(28,20,10,0.08) 34%, rgba(255,255,255,0.14) 100%)",
                      opacity: 0,
                    }}
                  />
                </div>
                {/* Contact shadow thrown forward onto the table */}
                <div
                  aria-hidden="true"
                  className="absolute inset-x-0 top-full h-[20%]"
                  style={{ transformOrigin: "50% 0", transform: "rotateX(90deg)", background: "linear-gradient(180deg, rgba(40,32,20,0.42), rgba(40,32,20,0) 85%)", filter: "blur(4px)" }}
                />
              </div>
            );
          })}
        </div>
      </div>

      <div className="relative mx-auto mt-2 flex max-w-md items-center justify-between px-4">
        <button
          type="button"
          onClick={() => step(-1)}
          aria-label="Previous print"
          className="grid h-10 w-10 place-items-center rounded-full text-stone-700 transition hover:bg-stone-900/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-stone-900"
        >
          <svg aria-hidden="true" width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
            <path d="M12 4l-6 6 6 6" />
          </svg>
        </button>
        <p aria-live="polite" className="text-center font-mono text-[11px] uppercase tracking-[0.18em] text-stone-700">
          <span className="tabular-nums">{String(active + 1).padStart(2, "0")}</span>
          <span className="mx-2 text-stone-500">/</span>
          {prints[active].title}
        </p>
        <button
          type="button"
          onClick={() => step(1)}
          aria-label="Next print"
          className="grid h-10 w-10 place-items-center rounded-full text-stone-700 transition hover:bg-stone-900/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-stone-900"
        >
          <svg aria-hidden="true" width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
            <path d="M8 4l6 6-6 6" />
          </svg>
        </button>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/concertina-fold-photo-book-carousel-that-opens-flat-at-the-centre.json

When to use it

How to customize it

Accessibility

Questions

How is the zigzag kept joined while one panel opens flat?

Every frame the code walks along the strip from left to right. Each panel gets an angle that depends on how far it is from the middle, and the next hinge is placed at the far end of the previous panel using the cosine and sine of that angle. Because each panel starts exactly where the last one ended, the strip cannot tear, however the angles change.

Why does the book stretch when I drag quickly?

The fold angle is reduced in proportion to the current speed, up to 30 percent. Real folded card does the same when you tug it: the pleats open before the far end starts to move. It is one line, the `stretch` value in `layout`.

Does it need a 3D library?

No. The panels are ordinary elements moved with `translate3d` and `rotateY` inside a parent that has `perspective`. The shading is a gradient layered over each panel whose opacity follows the sine of its angle, and the shadow on the table is a blurred gradient laid flat with `rotateX(90deg)`.

How many pictures can it hold?

Any number from four up. The component renders twice the number of prints so the strip can wrap round, which keeps the panel count even, and the zigzag needs an even count to alternate correctly. With more than ten pictures consider rendering each only once and stopping at the ends.

Can I use landscape photographs?

The panels are tall, so landscape shots are cropped hard. If your set is landscape, change the 1.44 height ratio to about 0.75 and widen the panel in `measure`. The fold maths does not care about the shape.

Related: Fashion Print Carousel in WebGL with Standing Prints That Rock on Their Corners over a Mirror Floor, Glossy Photo Print That Peels Off a Second Photograph with a Lit Curling Underside, Light Table Slide Gallery with a Glass Loupe That Glides Between Mounted Transparencies.

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