Copied!
Gallery

Unroll image on scroll

A poster that unrolls like a sheet of paper from a spiral roll as you scroll or hover.

← Library

Element · Vanilla JS + Canvas 2DUpdated November 5, 2026

ScrollElementVanilla JS + Canvas 2D

Live preview needs JavaScript. The code below works without any dependencies.

Prompt for your AI assistant

Paste this into Claude, Cursor or ChatGPT. The settings update as you move the controls above.

Build "Unroll image on scroll" for my site using the Enrichr Library element at https://enrichr.dev/elements/unroll-image-scroll.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/unroll-image-scroll.json (markup, CSS, JavaScript, parameters).
Requirements: vanilla JavaScript and CSS, no new dependencies; keep the behaviour of the source unchanged; respect prefers-reduced-motion; work on touch screens; keep it accessible.

Settings:
- direction: right
- radius: 22
- trigger: scroll
- manual: 0
- colorA: #005236
- colorB: #e6ff55

Verify: reduced motion shows no movement, it works at 375px wide, there are no console errors, and scrolling stays smooth.

The same spec as JSON, for tools that read it directly: /elements/unroll-image-scroll.json.

Code

Mount it on an element that contains the markup. It returns an object with update(options) and destroy().

HTML

<div class="ef-unr"><canvas class="ef-unr__c" role="img" aria-label="A poster that unrolls like a sheet of paper"></canvas></div>

CSS

/* Enrichr Library · https://enrichr.dev */
.ef-unr { position: relative; width: 100%; min-height: 20rem; overflow: hidden; background: var(--ef-unr-bg, #e9e3d3); background-image: radial-gradient(circle at 50% 40%, rgba(255, 255, 255, 0.55), rgba(0, 0, 0, 0.08)); }
.ef-unr__c { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
.ef-unr--hover { cursor: pointer; }
.ef-unr:focus-visible { outline: 3px solid #005236; outline-offset: -3px; }

JavaScript

// Enrichr Library · https://enrichr.dev
// Unroll image: a poster drawn on a canvas that unrolls like a sheet of paper. The loose end of the sheet curls up around a
// spiral roll; the flat part is drawn as thin strips of the picture and the roll is the same sheet wound on a shrinking
// radius, back side showing, sorted by depth. It unrolls with scroll (or on hover and focus) and rolls up again in reverse.
// Everything is drawn in code, so there is no image file. Reduced motion shows the poster fully unrolled.
// mount(root, { direction ("right" | "left" | "down" | "up": the way it unrolls), radius (core radius px), trigger ("scroll" | "hover"),
//               manual (0 = live, 1-100 = fixed unrolled %), colorA (poster green), colorB (accent) })
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var box = root.querySelector(".ef-unr");
  var canvas = root.querySelector(".ef-unr__c");
  var ctx = canvas.getContext("2d");
  var tex = document.createElement("canvas"), src = document.createElement("canvas");
  var opts = {}, raf = 0, visible = true, io = null, ro = null;
  var u = 0, target = 0, hov = false, lastDrawn = -1, dpr = 1, lenS = 1, brdB = 1, M = null;
  var PAPER = [236, 228, 208];

  function colorOk(v, fb) { return /^(#[0-9a-f]{3,8}|[a-z]{3,20}|rgba?\([\d\s.,%]+\))$/i.test(String(v)) ? String(v) : fb; }
  function art(c, w, h) {
    var g = c.createLinearGradient(0, 0, 0, h);
    g.addColorStop(0, "#0c1e18"); g.addColorStop(1, opts.colorA);
    c.fillStyle = g; c.fillRect(0, 0, w, h);
    var u0 = Math.min(w, h) / 100;
    c.strokeStyle = "rgba(251,248,241,0.16)"; c.lineWidth = Math.max(1, u0 * 0.25);
    for (var i = 1; i <= 5; i++) { c.beginPath(); c.arc(w * 0.72, h * 0.34, u0 * (14 + i * 9), 0, 6.2832); c.stroke(); }
    c.fillStyle = opts.colorB; c.beginPath(); c.arc(w * 0.72, h * 0.34, u0 * 13, 0, 6.2832); c.fill();
    var layers = [["rgba(251,248,241,0.10)", 0.62, 0.05, 1.7], ["rgba(251,248,241,0.16)", 0.72, 0.06, 2.6], ["#0c1e18", 0.84, 0.05, 3.9]];
    layers.forEach(function (L, k) {
      c.fillStyle = L[0]; c.beginPath(); c.moveTo(0, h);
      for (var x = 0; x <= w; x += w / 60) c.lineTo(x, h * L[1] - Math.abs(Math.sin(x / w * 5 + L[3])) * h * L[2] * 2 - Math.sin(x / w * 11 + k) * h * 0.012);
      c.lineTo(w, h); c.closePath(); c.fill();
    });
    c.fillStyle = "#fbf8f1"; c.textBaseline = "alphabetic";
    c.font = "900 " + (u0 * 17).toFixed(1) + "px Helvetica Neue, Arial, Segoe UI, system-ui, sans-serif";
    c.fillText("UNROLL", w * 0.07, h * 0.19 + u0 * 8);
    c.fillStyle = opts.colorB; c.fillRect(w * 0.07, h * 0.19 + u0 * 12, u0 * 14, Math.max(2, u0 * 1.2));
  }
  function build() {
    var r = box.getBoundingClientRect();
    dpr = Math.min(2, window.devicePixelRatio || 1);
    var w = Math.max(40, Math.round(r.width * dpr)), h = Math.max(40, Math.round(r.height * dpr));
    canvas.width = w; canvas.height = h;
    tex.width = w; tex.height = h; art(tex.getContext("2d"), w, h);
    var d = opts.direction;
    var m = d === "left" ? [-1, 0, 0, 1, w, 0] : d === "down" ? [0, 1, 1, 0, 0, 0] : d === "up" ? [0, -1, 1, 0, 0, h] : [1, 0, 0, 1, 0, 0];
    var vert = d === "down" || d === "up";
    lenS = vert ? h : w; brdB = vert ? w : h;
    M = m;
    src.width = lenS; src.height = brdB;
    var sc = src.getContext("2d");
    var inv = new DOMMatrix(m).inverse();
    sc.setTransform(inv.a, inv.b, inv.c, inv.d, inv.e, inv.f);
    sc.drawImage(tex, 0, 0);
    lastDrawn = -1;
    draw();
  }
  function draw() {
    if (Math.abs(u - lastDrawn) < 0.0003) return;
    lastDrawn = u;
    var cw = canvas.width, ch = canvas.height;
    ctx.setTransform(1, 0, 0, 1, 0, 0);
    ctx.clearRect(0, 0, cw, ch);
    ctx.setTransform(M[0], M[1], M[2], M[3], M[4], M[5]);
    var S = lenS, B = brdB, R0 = Math.max(3, opts.radius) * dpr, k = (1.7 * dpr) / 6.2832;
    var L = u * S, rem = S - L;
    var Rout = Math.sqrt(R0 * R0 + 2 * k * rem), thMax = (Rout - R0) / k;
    var Rout0 = Math.sqrt(R0 * R0 + 2 * k * S);
    var shift = Math.max(0, Rout0 - L);
    if (L > 0.5) {
      // soft shadow cast by the roll onto the flat sheet
      ctx.drawImage(src, 0, 0, L, B, shift, 0, L, B);
      if (rem > 1) {
        var sg = ctx.createLinearGradient(L + shift - Rout * 3, 0, L + shift, 0);
        sg.addColorStop(0, "rgba(0,0,0,0)"); sg.addColorStop(1, "rgba(0,0,0,0.30)");
        ctx.fillStyle = sg; ctx.fillRect(Math.max(shift, L + shift - Rout * 3), 0, Math.min(L, Rout * 3), B);
      }
    }
    if (rem <= 1) return;
    var strips = [], x = L + shift, z = 0, step = 0.09, n = Math.max(1, Math.ceil(thMax / step)), prevTh = 0, prevS = 0;
    for (var j = 1; j <= n; j++) {
      var th = Math.min(thMax, j * step);
      var s = Rout * th - k * th * th / 2, ds = s - prevS, mid = (th + prevTh) / 2;
      var dx = Math.cos(mid) * ds, dz = Math.sin(mid) * ds;
      strips.push({ x: x, dx: dx, z: z + dz / 2, s0: L + prevS, ds: ds, c: Math.cos(mid) });
      x += dx; z += dz; prevS = s; prevTh = th;
    }
    strips.sort(function (a, b) { return a.z - b.z; });
    var D = 1100 * dpr;
    strips.forEach(function (t) {
      var w = Math.abs(t.dx);
      if (w < 0.25) return;
      var sc = 1 / (1 - Math.min(0.5, t.z / D)), h = B * sc, y = (B - h) / 2, x0 = t.dx > 0 ? t.x : t.x + t.dx;
      if (t.c > 0) {
        ctx.drawImage(src, t.s0, 0, t.ds, B, x0, y, Math.max(w, 0.7), h);
        ctx.fillStyle = "rgba(0,0,0," + (0.55 * (1 - t.c)).toFixed(3) + ")";
        ctx.fillRect(x0, y, Math.max(w, 0.7), h);
      } else {
        var f = 0.5 + 0.5 * Math.abs(t.c);
        ctx.fillStyle = "rgb(" + Math.round(PAPER[0] * f) + "," + Math.round(PAPER[1] * f) + "," + Math.round(PAPER[2] * f) + ")";
        ctx.fillRect(x0, y, Math.max(w, 0.7), h);
      }
    });
  }
  function scrollTarget() {
    var r = box.getBoundingClientRect(), vh = window.innerHeight || 800;
    return Math.min(1, Math.max(0, (vh * 0.95 - r.top) / (vh * 0.6)));
  }
  function want() {
    if (reduce) return 1;
    if (opts.manual > 0) return opts.manual / 100;
    return opts.trigger === "hover" ? (hov ? 1 : 0) : scrollTarget();
  }
  function tick() {
    raf = requestAnimationFrame(tick);
    if (!visible) return;
    target = want();
    u += (target - u) * 0.12;
    if (Math.abs(target - u) < 0.0015) u = target;
    draw();
  }
  function on() { hov = true; }
  function off() { hov = false; }
  function update(next) {
    var prevDir = opts.direction;
    opts = Object.assign({ direction: "right", radius: 22, trigger: "scroll", manual: 0, colorA: "#005236", colorB: "#e6ff55" }, opts, next || {});
    opts.radius = Number(opts.radius); opts.manual = Number(opts.manual);
    opts.colorA = colorOk(opts.colorA, "#005236"); opts.colorB = colorOk(opts.colorB, "#e6ff55");
    if (["right", "left", "down", "up"].indexOf(opts.direction) < 0) opts.direction = "right";
    var hover = opts.trigger === "hover";
    box.classList.toggle("ef-unr--hover", hover);
    if (hover && !reduce) { box.tabIndex = 0; box.setAttribute("role", "group"); box.setAttribute("aria-label", "Poster that unrolls on hover or focus"); }
    else { box.removeAttribute("tabindex"); box.removeAttribute("role"); box.removeAttribute("aria-label"); }
    u = target = want();
    build();
  }
  update(o);
  box.addEventListener("pointerenter", on); box.addEventListener("pointerleave", off);
  box.addEventListener("focus", on); box.addEventListener("blur", off);
  if ("ResizeObserver" in window) { ro = new ResizeObserver(function () { build(); }); ro.observe(box); }
  if (!reduce) {
    if ("IntersectionObserver" in window) { io = new IntersectionObserver(function (e) { visible = e[0].isIntersecting; }); io.observe(box); }
    raf = requestAnimationFrame(tick);
  }
  return { update: update, destroy: function () {
    cancelAnimationFrame(raf); if (io) io.disconnect(); if (ro) ro.disconnect();
    box.removeEventListener("pointerenter", on); box.removeEventListener("pointerleave", off);
    box.removeEventListener("focus", on); box.removeEventListener("blur", off);
  } };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"direction":"right","radius":22,"trigger":"scroll","manual":0,"colorA":"#005236","colorB":"#e6ff55"});

How it works

The sheet is treated as a strip of paper with a fixed end and a loose end. The unrolled part is drawn flat. The rest is wound into a spiral whose radius shrinks from the outer turn down to the core radius, which is the curve you get from paper of fixed thickness. The spiral is walked in small angle steps, and each step becomes one thin vertical strip: strips facing the viewer show a slice of the picture squeezed by the cosine of the angle and shaded, and strips facing away show the cream back of the paper. The strips are sorted by depth and drawn back to front, with a slight size increase for the ones nearer the viewer. A shared transform lets the same code unroll in any of four directions.

Accessibility and performance

The canvas has a text label, and in hover mode the block is focusable so keyboard users can unroll it by focusing it. The picture is decorative. With prefers-reduced-motion the poster is shown fully unrolled and nothing animates.

A single 2D canvas redrawn only while the unrolled amount is changing, with a few hundred small rectangles and image slices per frame at most. The canvas resolution is capped at 2x and drawing pauses off screen.

SectionGallery
TechniquesScroll-driven, Reveal and intro, Pointer interaction
StackVanilla JS, Canvas 2D
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
Inspired byHow to Unroll Images with Three.js by Yuri Artiukh on Codrops. Original code, written from scratch.

Find this technique on a real site

Want to see this technique in the wild? Check any site with the free tools, then build your own version from this page.

Check which animation library a site uses · Extract a site's design tokens · Browse site breakdowns

Licence and provenance

Free to use in personal and client projects during the preview. Final licence terms will be published here before any paid tier starts; ask before reselling the code as a collection.

This is original code. The technique is a common one that we studied by measuring award-winning sites with Enrichr; no third-party code, shaders, fonts or images are used.

Image compare on scrollA before and after slider whose divider is scrubbed by scroll position and can also be dragged or keyboard-operated. Mask shape sectionsThe same image revealed through a circle, a diamond and a band as each section scrolls into view. Clip reveal menuText rows that open a generated pattern behind the word with a clip-path transition on hover, while the word inverts along the edge.

FAQ

Can I use my own image?

Yes. Replace the drawing function with a drawImage call that fills the texture canvas, and the rest of the code unrolls whatever is on it.

How is this different from a CSS clip reveal?

A clip reveal only uncovers the picture. Here the loose end of the paper stays visible as a physical roll with a back side, so the reveal reads as an object.

What does the radius control do?

It sets the core of the roll. A small value gives a tight scroll, and a large one a fat tube. The outer size also grows with how much paper is still wound up.