← 3D & depth

Image Helix

Images sit on a spiral staircase in 3D and the whole helix turns slowly round its axis, so they seem to climb as it goes. The ones round the back fade into the shadow. Drag it sideways to spin it yourself.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-image-helix to a box with a height, holding your images (or any other elements).
  3. The script sizes and places them to fit the box.

Options

Add any of these attributes to the same element.

data-turnDegrees between neighbours. Default 36, ten to a full turn.
data-speedHow fast it turns, in degrees per second. Default 18; negative turns the other way.
data-radiusRadius of the helix in px. Default fits the box.
data-ratioHeight of each item over its width. Default 1.25.
data-tiltHow far you look down on it, in degrees. Default -8.
data-drag="false"Don’t let people spin it by dragging.

It’s paused when off screen. With reduced motion the helix stands still.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Image Helix: images sit on a spiral staircase in 3D, turning slowly
    // round its axis, so they seem to climb as it turns. The ones round the
    // back are dimmed. Drag it to spin it by hand.
    // Usage:
    //   <div data-image-helix style="height: 380px">
    //     <img src="1.jpg" alt="…"> <img src="2.jpg" alt="…"> …
    //   </div>
    // Any children work, not only images. The script sizes them to fit.
    // Optional: data-turn (degrees between neighbours, default 36: ten to a
    // full turn), data-speed (degrees per second, default 18; negative turns
    // the other way), data-radius (px, default fits the box), data-tilt
    // (degrees you look down on it, default -8), data-ratio (height / width
    // of each item, default 1.25), data-drag="false".
    var DEFAULTS = { turn: 36, speed: 18, tilt: -8, ratio: 1.25 };

    var css =
      '.ihx{position:relative;overflow:hidden;perspective:1000px;touch-action:pan-y;user-select:none;-webkit-user-select:none}' +
      '.ihx-drag{cursor:grab}.ihx-drag:active{cursor:grabbing}' +
      '.ihx-spin{position:absolute;left:50%;top:50%;width:0;height:0;transform-style:preserve-3d}' +
      '.ihx-item{position:absolute;left:0;top:0;box-sizing:border-box;object-fit:cover;-webkit-user-drag:none;pointer-events:none;will-change:transform,opacity}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    function setup(el) {
      var d = el.dataset;
      var items = Array.from(el.children);
      if (!items.length) return;
      el.classList.add('ihx');
      var spin = document.createElement('div');
      spin.className = 'ihx-spin';
      items.forEach(function (it) { it.classList.add('ihx-item'); spin.appendChild(it); });
      el.appendChild(spin);
      var n = items.length;
      var turn = d.turn !== undefined ? +d.turn : DEFAULTS.turn;
      var speed = d.speed !== undefined ? +d.speed : DEFAULTS.speed;
      var tilt = d.tilt !== undefined ? +d.tilt : DEFAULTS.tilt;
      var ratio = +d.ratio || DEFAULTS.ratio;
      var s = { rot: 0, v: 0, dragging: false };
      var r = 0, pitch = 0, iw = 0, ih = 0;

      function size() {
        var w = el.clientWidth, h = el.clientHeight;
        if (!w || !h) return;
        var arc = turn * Math.PI / 180;
        // Width of each item: as wide as its slice of the circle, near enough.
        r = +d.radius || Math.min(w * 0.34, h * 0.42, 260);
        iw = Math.max(24, r * arc * 0.9);
        ih = iw * ratio;
        // Spread the items over the height, leaving room for the end ones.
        pitch = n > 1 ? Math.max(0, (h * 0.9 - ih) / (n - 1)) : 0;
        // One turn apart should never overlap.
        pitch = Math.max(pitch, (ih * 1.08) * turn / 360);
        items.forEach(function (it, i) {
          it.style.width = iw + 'px';
          it.style.height = ih + 'px';
          it.style.marginLeft = -iw / 2 + 'px';
          it.style.marginTop = -ih / 2 + 'px';
          it.style.transform = 'translateY(' + ((i - (n - 1) / 2) * pitch) + 'px) rotateY(' + i * turn + 'deg) translateZ(' + r + 'px)';
        });
        draw();
      }

      function draw() {
        spin.style.transform = 'translateZ(' + -r + 'px) rotateX(' + tilt + 'deg) rotateY(' + s.rot + 'deg)';
        // Dim the ones round the back.
        items.forEach(function (it, i) {
          var facing = Math.cos((i * turn + s.rot) * Math.PI / 180);
          it.style.opacity = (0.3 + 0.7 * (facing + 1) / 2).toFixed(3);
          it.style.filter = facing < 0 ? 'brightness(' + (1 + facing * 0.35).toFixed(3) + ')' : '';
        });
      }

      size();
      if (window.ResizeObserver) new ResizeObserver(size).observe(el);
      if (reduced()) return;

      if (d.drag !== 'false') {
        el.classList.add('ihx-drag');
        var lastX = 0, lastT = 0;
        el.addEventListener('pointerdown', function (e) {
          s.dragging = true;
          s.v = 0;
          lastX = e.clientX;
          lastT = performance.now();
          el.setPointerCapture(e.pointerId);
        });
        el.addEventListener('pointermove', function (e) {
          if (!s.dragging) return;
          var now = performance.now(), dx = (e.clientX - lastX) * 0.4;
          s.rot += dx;
          s.v = dx / Math.max(8, now - lastT) * 1000;
          lastX = e.clientX;
          lastT = now;
          draw();
        });
        var end = function () { s.dragging = false; };
        el.addEventListener('pointerup', end);
        el.addEventListener('pointercancel', end);
      }

      var last = 0;
      var timer = anime.createTimer({
        loop: true,
        duration: 1000,
        autoplay: false,
        onUpdate: function () {
          var now = performance.now();
          var dt = last ? Math.min(50, now - last) / 1000 : 0.016;
          last = now;
          if (s.dragging) return;
          // Coast after a drag, easing back into the steady turn.
          s.v += (-speed - s.v) * Math.min(1, dt * 1.6);
          s.rot += s.v * dt;
          draw();
        }
      });
      s.v = -speed;
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
      }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-image-helix]').forEach(function (el) {
        if (el._ihx) return;
        el._ihx = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-image-helix style="height: 380px">
  <img src="one.jpg" alt="…">
  <img src="two.jpg" alt="…">
  <img src="three.jpg" alt="…">
  <!-- … about 12 to 20 works well -->
</div>