Glossy Photo Print That Peels Off a Second Photograph with a Lit Curling Underside

Two photographic prints lie stacked on a pale table. The bottom right corner of the top one is already lifting, and it follows the pointer: the paper rolls over a soft cylinder, the picture side darkens and catches a streak of gloss as it climbs, and the back comes over white, shaded along the roll and tinted by the colour of the print beneath. The lifted paper drops two shadows, a tight one where it leaves the table and a wide soft one further out. Click, tap, or let go of the corner far enough across, and the print is pulled clean off the stack and the other photograph is on top. Left alone, the corner breathes and the prints change places every few seconds. On a phone the print shrinks to the width of the screen and a sideways swipe across it lifts the corner.

Code

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

// Two prints stacked on the table. Peeling the top one off uncovers the other.
const prints = [
  {
    name: "Rosé",
    src: "https://images.pexels.com/photos/28209648/pexels-photo-28209648.jpeg?auto=compress&cs=tinysrgb&w=900",
    alt: "A woman in a red dress, patterned headscarf and cat-eye sunglasses resting her chin on her hand against a pale pink wall",
    tone: "#e3c3cb",
  },
  {
    name: "Verdigris",
    src: "https://images.pexels.com/photos/9489589/pexels-photo-9489589.jpeg?auto=compress&cs=tinysrgb&w=900",
    alt: "A woman in a green beret, green jacket and green gloves looking down at a green ball in her hands against a green wall",
    tone: "#2f6b3a",
  },
];

/** Radius of the curl as a share of the print's height. Bigger is a lazier, fatter roll. */
const CURL = 0.085;
/** How far the corner has to travel (in print heights) before letting go pulls the print right off. */
const LET_GO = 0.8;

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 vec4 u_rect;
uniform sampler2D u_a;
uniform sampler2D u_b;
uniform vec2 u_img;
uniform vec2 u_c;
uniform vec2 u_d;
uniform float u_r;
uniform float u_fade;
uniform float u_top;
float A;
float PX;
const float PI = 3.14159265;

// Each print has a white border, as a lab print does. BORDER is its width as a share of the print height.
const float BORDER = 0.026;
vec2 fit(vec2 q, float img){
  vec2 size = vec2(A, 1.0) - 2.0 * BORDER;
  vec2 uv = (q - BORDER) / size;
  float inner = size.x / size.y;
  if (img > inner) uv.x = (uv.x - 0.5) * inner / img + 0.5;
  else uv.y = (uv.y - 0.5) * img / inner + 0.5;
  return clamp(uv, 0.0, 1.0);
}
vec3 bordered(vec3 c, vec2 q){
  vec2 e = min(q, vec2(A, 1.0) - q);
  return mix(vec3(0.958, 0.952, 0.936), c, smoothstep(BORDER - PX, BORDER + PX, min(e.x, e.y)));
}
vec3 topPrint(vec2 q){ return bordered(mix(texture2D(u_a, fit(q, u_img.x)).rgb, texture2D(u_b, fit(q, u_img.y)).rgb, u_top), q); }
vec3 lowPrint(vec2 q){ return bordered(mix(texture2D(u_b, fit(q, u_img.y)).rgb, texture2D(u_a, fit(q, u_img.x)).rgb, u_top), q); }
// 1 inside the sheet, 0 outside, with an edge w wide.
float cov(vec2 q, float w){
  vec2 e = min(q, vec2(A, 1.0) - q);
  return clamp(min(e.x, e.y) / w + 0.5, 0.0, 1.0);
}
float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
// How much of the lifted paper (the roll and the flap that has come over) sits above q.
float lifted(vec2 q, float w){
  float t = dot(q - u_c, u_d);
  float ridge = 1.0 - smoothstep(u_r - w * 0.5, u_r + w * 0.5, t);
  if (t < 0.0) return cov(q + u_d * (PI * u_r - 2.0 * t), w);
  float th = asin(clamp(t / u_r, 0.0, 1.0));
  return cov(q + u_d * (u_r * (PI - th) - min(t, u_r)), w) * ridge;
}

void main(){
  vec2 px = vec2(gl_FragCoord.x, u_res.y - gl_FragCoord.y);
  PX = 1.0 / u_rect.w;
  A = u_rect.z / u_rect.w;
  vec2 q = (px - u_rect.xy) * PX;
  float t = dot(q - u_c, u_d);
  vec2 fall = normalize(vec2(0.50, 0.86));
  float lift = 2.0 * u_r;

  // Shadow of the lifted paper: a tight dark one where it leaves the table and a wide soft one further out.
  float near = lifted(q - fall * lift * 0.30, 0.018 + lift * 0.10);
  float far = lifted(q - fall * lift * 0.95, 0.03 + lift * 0.30);
  float dark = clamp(near * 0.42 + far * 0.34, 0.0, 1.0) * u_fade;

  vec3 col = vec3(0.0);
  float al = dark * 0.36 * cov(q, 0.30);

  // The print underneath.
  float lowIn = cov(q, PX);
  vec3 low = lowPrint(q);
  low *= 1.0 - dark;
  col = mix(col, low, lowIn);
  al = mix(al, 1.0, lowIn);

  // The top print, picture side. Flat where it still lies down, then climbing the roll.
  float frontIn;
  vec3 front;
  if (t < 0.0) {
    frontIn = cov(q, PX);
    front = topPrint(q) * (1.0 - dark * 0.85);
    // The gloss coat picks up a breath of light just before the paper leaves the table.
    front += vec3(1.0) * 0.07 * exp(t / (u_r * 0.9));
  } else {
    float th = asin(clamp(t / u_r, 0.0, 1.0));
    vec2 qo = q + u_d * (u_r * th - t);
    frontIn = cov(qo, PX) * (1.0 - smoothstep(u_r - PX, u_r + PX, t));
    float up = sin(th);
    front = topPrint(qo) * mix(1.0, 0.36, pow(up, 1.6));
    front += vec3(1.0, 0.98, 0.95) * 0.34 * exp(-pow((th - 0.50) / 0.20, 2.0));
  }
  col = mix(col, front, frontIn * u_fade);
  al = mix(al, 1.0, frontIn * u_fade);

  // The back of the top print: over the roll, then lying flat, lit from above.
  float backIn;
  float lit;
  vec2 qb;
  float turn;
  if (t < 0.0) {
    qb = q + u_d * (PI * u_r - 2.0 * t);
    backIn = cov(qb, PX);
    lit = 0.985 - 0.07 * smoothstep(0.0, 0.7, -t);
    turn = 0.0;
  } else {
    float th = asin(clamp(t / u_r, 0.0, 1.0));
    qb = q + u_d * (u_r * (PI - th) - t);
    backIn = cov(qb, PX) * (1.0 - smoothstep(u_r - PX, u_r + PX, t));
    float k = cos(th);
    lit = 0.50 + 0.50 * pow(k, 0.8) + 0.05 * exp(-pow((k - 0.78) / 0.16, 2.0));
    turn = 1.0 - k;
  }
  vec3 paper = vec3(0.972, 0.962, 0.940) * lit;
  // Colour thrown up onto the underside by the print below it.
  vec2 under = q + u_d * u_r;
  vec3 bounce = (lowPrint(under + vec2(0.07, 0.0)) + lowPrint(under - vec2(0.07, 0.0)) + lowPrint(under + vec2(0.0, 0.07)) + lowPrint(under - vec2(0.0, 0.07))) * 0.25;
  paper = mix(paper, paper * (0.45 + bounce * 1.1), 0.36 * turn * lowIn);
  // Paper tooth, and the maker's ruled marks that photo paper carries on its back.
  paper *= 1.0 - 0.030 * hash(floor(qb * vec2(1100.0, 420.0)));
  float rule = step(0.90, fract((qb.x + qb.y) * 9.0)) * step(0.45, fract((qb.y - qb.x) * 4.5));
  paper *= 1.0 - 0.045 * rule;
  col = mix(col, paper, backIn * u_fade);
  al = mix(al, 1.0, backIn * u_fade);

  gl_FragColor = vec4(col, al);
}`;

export default function GlossyPhotoPrintThatPeelsOffASecondPhotographWithALitCurlingUnderside() {
  const reduce = useReducedMotion();
  const stage = useRef<HTMLElement>(null);
  const print = useRef<HTMLButtonElement>(null);
  const canvas = useRef<HTMLCanvasElement>(null);
  const first = useRef<HTMLImageElement>(null);
  const second = useRef<HTMLImageElement>(null);
  /** Where a person is holding the corner (in print heights from its top left), when they last moved, and a request to pull it off. */
  const hand = useRef({ x: 0, y: 0, at: -1e6, pull: false });
  const [live, setLive] = useState(false);
  const [top, setTop] = useState(0);

  useEffect(() => {
    const host = stage.current;
    const el = print.current;
    const cv = canvas.current;
    const one = first.current;
    const two = second.current;
    if (reduce || !host || !el || !cv || !one || !two) return;
    const gl = cv.getContext("webgl", { antialias: false, alpha: true, premultipliedAlpha: true });
    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_a"), 0);
    gl.uniform1i(u("u_b"), 1);
    const uni = { res: u("u_res"), rect: u("u_rect"), img: u("u_img"), c: u("u_c"), d: u("u_d"), r: u("u_r"), fade: u("u_fade"), top: u("u_top") };

    let ready = false;
    let visible = true;
    let raf = 0;
    let last = 0;
    let ratio = 1;
    let aspect = 0.8;
    let onTop = 0;
    let flying = false;
    let idle = 0;
    const started = performance.now();
    // The corner starts already lifted so the first frame shows what the piece is.
    const pos = { x: 0.8 - 0.36, y: 1 - 0.3 };
    const vel = { x: 0, y: 0 };

    const upload = () => {
      if (ready || !one.complete || !two.complete || !one.naturalWidth || !two.naturalWidth) return;
      try {
        [one, two].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);
        });
        gl.uniform2f(uni.img, one.naturalWidth / one.naturalHeight, two.naturalWidth / two.naturalHeight);
        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 = () => {
      ratio = Math.min(window.devicePixelRatio || 1, 2);
      cv.width = Math.max(1, Math.round(host.clientWidth * ratio));
      cv.height = Math.max(1, Math.round(host.clientHeight * ratio));
      gl.viewport(0, 0, cv.width, cv.height);
      const outer = host.getBoundingClientRect();
      const inner = el.getBoundingClientRect();
      aspect = inner.width / inner.height;
      gl.uniform2f(uni.res, cv.width, cv.height);
      gl.uniform4f(uni.rect, (inner.left - outer.left) * ratio, (inner.top - outer.top) * ratio, inner.width * ratio, inner.height * ratio);
    };

    const loop = (stamp: number) => {
      if (ready) {
        const t = (stamp - started) / 1000;
        const dt = Math.min(last ? (stamp - last) / 1000 : 0.016, 0.033);
        last = stamp;
        const kx = aspect;
        const ky = 1;
        const held = stamp - hand.current.at < 2200;
        if (hand.current.pull) {
          hand.current.pull = false;
          flying = true;
        }
        let tx: number;
        let ty: number;
        let stiff = 46;
        if (flying) {
          // Pulled clean off: the corner heads up and away past the far side of the print.
          tx = kx - 3.4;
          ty = ky - 1.5;
          stiff = 9;
        } else if (held) {
          tx = hand.current.x;
          ty = hand.current.y;
          idle = 0;
        } else {
          // Nobody is holding it: the corner lifts and settles by itself, and now and then the print comes away.
          idle += dt;
          const reach = 0.46 + 0.2 * Math.sin(t * 0.85 - 0.6);
          const lean = -2.5 + 0.2 * Math.sin(t * 0.53);
          tx = kx + Math.cos(lean) * reach;
          ty = ky + Math.sin(lean) * reach;
          if (idle > 6.4) flying = true;
        }
        // The paper does not stretch: the corner cannot go further from the two pinned corners than the edges are long.
        if (!flying) {
          const pin = (cx: number, cy: number, max: number) => {
            const dx = tx - cx;
            const dy = ty - cy;
            const far = Math.hypot(dx, dy);
            if (far > max) {
              tx = cx + (dx / far) * max;
              ty = cy + (dy / far) * max;
            }
          };
          pin(kx, 0, 1);
          pin(0, ky, kx);
        }
        const damp = 2 * Math.sqrt(stiff) * (flying ? 1 : 0.72);
        vel.x += ((tx - pos.x) * stiff - vel.x * damp) * dt;
        vel.y += ((ty - pos.y) * stiff - vel.y * damp) * dt;
        pos.x += vel.x * dt;
        pos.y += vel.y * dt;

        let dx = kx - pos.x;
        let dy = ky - pos.y;
        let gone = Math.hypot(dx, dy);
        if (flying && gone > 2.9) {
          // The top print has left. The one underneath is now on top, with the other slid in below it.
          onTop = 1 - onTop;
          setTop(onTop);
          flying = false;
          idle = 0;
          pos.x = kx - 0.02;
          pos.y = ky - 0.015;
          vel.x = 0;
          vel.y = 0;
          dx = 0.02;
          dy = 0.015;
          gone = 0.025;
        }
        if (gone < 0.004) {
          dx = 0.0032;
          dy = 0.0024;
          gone = 0.004;
        }
        const r = Math.min(CURL, (gone / Math.PI) * 0.8);
        const along = (gone + Math.PI * r) / 2;
        gl.uniform2f(uni.d, dx / gone, dy / gone);
        gl.uniform2f(uni.c, kx - (dx / gone) * along, ky - (dy / gone) * along);
        gl.uniform1f(uni.r, r);
        gl.uniform1f(uni.fade, flying ? 1 - Math.min(1, Math.max(0, (gone - 2.0) / 0.8)) : 1);
        gl.uniform1f(uni.top, onTop);
        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(host);
    const io = new IntersectionObserver(([entry]) => {
      visible = entry.isIntersecting;
      wake();
    });
    io.observe(host);
    document.addEventListener("visibilitychange", wake);
    one.addEventListener("load", upload);
    two.addEventListener("load", upload);
    upload();
    resize();
    wake();

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

  function hold(e: ReactPointerEvent<HTMLButtonElement>) {
    const r = e.currentTarget.getBoundingClientRect();
    hand.current = { ...hand.current, x: (e.clientX - r.left) / r.height, y: (e.clientY - r.top) / r.height, at: performance.now() };
  }
  /** Letting go far enough from the corner, or pressing the button from the keyboard, takes the print off. */
  function release(e: ReactPointerEvent<HTMLButtonElement>) {
    const r = e.currentTarget.getBoundingClientRect();
    const far = Math.hypot(r.width / r.height - hand.current.x, 1 - hand.current.y);
    if (far > LET_GO) hand.current = { ...hand.current, pull: true, at: -1e6 };
    void e;
  }
  function takeOff() {
    if (live) hand.current = { ...hand.current, pull: true, at: -1e6 };
    else setTop((n) => 1 - n);
  }

  const upper = prints[top];
  const lower = prints[1 - top];

  return (
    <section ref={stage} className="relative isolate flex min-h-[780px] w-full flex-col items-center justify-center overflow-hidden bg-[#e7e2d8] px-4 py-16">
      <style>{`
        .gpp-cut { clip-path: polygon(0 0, 100% 0, 100% 58%, 46% 100%, 0 100%); }
        .gpp-flap { clip-path: polygon(100% 0, 0 100%, 21% 9%); background: linear-gradient(128deg, #b9b3a6 0%, #f7f4ec 34%, #fffdf8 62%, #dcd6c9 100%); }
      `}</style>
      <div aria-hidden="true" className="absolute inset-0 -z-10" style={{ background: "radial-gradient(70% 60% at 42% 34%, #f6f2ea 0%, #e7e2d8 55%, #d6d0c4 100%)" }} />

      <button
        ref={print}
        type="button"
        aria-label={`Peel off the ${upper.name} print to show ${lower.name} underneath. Move the pointer over the print to lift its corner.`}
        onClick={takeOff}
        onPointerMove={hold}
        onPointerDown={hold}
        onPointerUp={release}
        className="relative block aspect-[4/5] w-[min(78vw,400px)] cursor-grab touch-pan-y bg-[#cfc9bc] shadow-[0_1px_1px_rgba(60,48,30,0.25),0_18px_26px_-14px_rgba(60,48,30,0.45),0_50px_70px_-36px_rgba(60,48,30,0.5)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-stone-900 focus-visible:ring-offset-8 focus-visible:ring-offset-[#e7e2d8] active:cursor-grabbing"
      >
        {/* The still version: the lower print with the top one cut back at its corner. Shown with reduced motion or without WebGL. */}
        <img ref={first} src={prints[0].src} alt={prints[0].alt} crossOrigin="anonymous" width={900} height={1125} style={{ backgroundColor: prints[0].tone }} className={`absolute inset-0 box-border h-full w-full border-[10px] border-[#f5f3ee] object-cover ${top === 0 ? "gpp-cut z-10" : ""}`} />
        <img ref={second} src={prints[1].src} alt={prints[1].alt} crossOrigin="anonymous" width={900} height={1125} style={{ backgroundColor: prints[1].tone }} className={`absolute inset-0 box-border h-full w-full border-[10px] border-[#f5f3ee] object-cover ${top === 1 ? "gpp-cut z-10" : ""}`} />
        <span aria-hidden="true" className="gpp-flap absolute bottom-0 right-0 z-10 block h-[42%] w-[54%] drop-shadow-[6px_10px_10px_rgba(40,30,15,0.35)]" />
      </button>
      <canvas ref={canvas} aria-hidden="true" className={`pointer-events-none absolute inset-0 z-20 h-full w-full transition-opacity duration-300 ${live ? "opacity-100" : "opacity-0"}`} />

      <div className="relative z-30 mt-9 flex w-[min(78vw,400px)] items-baseline justify-between gap-4 text-[13px]">
        <p className="text-stone-600">
          <span className="font-semibold text-stone-900">{upper.name}</span>
          <span className="mx-2 text-stone-500">on</span>
          {lower.name}
        </p>
        <p className="text-stone-600">Peel the corner</p>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/glossy-photo-print-that-peels-off-a-second-photograph-with-a-lit-curling-underside.json

When to use it

How to customize it

Accessibility

Questions

How is the curl worked out?

Each frame the code takes where the corner is now and where it started, and from those finds a fold line and a roll radius. The shader then asks, for every pixel, how far past the fold it is. Before the fold the print lies flat. Within one radius the paper is climbing a cylinder, so the picture is sampled further along by the arc length. The same arithmetic run the other way round gives the back of the sheet where it has come over the top.

Why does the underside look lit and not flat white?

Its brightness depends on how far round the roll each point is: darkest where the paper stands on edge, brightest on the crown. It also takes a little colour from the photograph lying under it, most strongly on the steep part of the roll, the way white card picks up a tint from whatever it is held over.

Can I peel from a different corner?

The grabbed corner is `kx, ky` in the loop, set to the bottom right. Use `0, 1` for the bottom left, change the two `pin` calls to the corners next to it, and flip the sign of the idle `lean` so the corner travels up and to the right.

Will the paper tear or stretch if I drag it somewhere odd?

No. Two limits keep the corner no further from its neighbouring corners than the edges of the sheet are long, which is the rule real paper obeys. The limits are lifted only once the print has been let go and is leaving the table.

How do I chain more than two photographs?

The shader holds two textures and a switch for which is on top. To run through a longer set, upload the next picture into the texture that has just left each time the prints change places, which is the block that flips `onTop` in the loop.

Related: Portrait Print with a Liquid Glass Bead That Chases the Pointer and Shows a Second Photograph, Photo Proof Roll Carousel That Feeds over a Roller and Curls onto the Table, Tear-Off Desk Calendar with Pages That Peel, Curl and Flutter Away.

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