← Filters & retro

Kaleidoscope

Turn any picture into a kaleidoscope. The image is cut into mirrored wedges arranged around the centre, and the picture inside them slowly drifts and turns, so the pattern keeps blooming into new shapes. Drag a finger or move the pointer over it to give it a twist.

How to use it

  1. Paste the snippet just before </head>.
  2. Wrap an image in an element with data-kaleidoscope and give the wrapper a size.
  3. Use an image from your own site (or an inline SVG). Busy, colourful pictures make the best patterns.

Options

Add any of these attributes to the same element.

data-segmentsHow many slices, an even number. Default 12.
data-speedHow fast it drifts and turns. Default 1.
data-zoomHow far into the picture each slice looks. Default 1; higher shows less of it, bigger.
data-shapefill (default) fills the box; circle shows a round eyepiece.
data-autoplayfalse stops it drifting by itself, so it only moves when the pointer or a finger moves over it.

With reduced motion it shows a still pattern.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Kaleidoscope: cuts an image into mirrored wedge-shaped slices arranged
    // around the centre, like looking down a kaleidoscope. The picture inside
    // the slices slowly drifts and turns, so the pattern keeps blooming.
    // Usage: <figure data-kaleidoscope><img src="photo.jpg" alt="…"></figure>
    // The image (an <img> on the same site, or an inline <svg>) stays in the
    // page for screen readers; give the wrapper a size.
    // Optional: data-segments (how many slices, an even number, default 12),
    // data-speed (how fast it moves, default 1), data-zoom (how far into the
    // picture it looks, default 1), data-shape ("fill", the default, or
    // "circle" for a round eyepiece), data-autoplay ("false" stops it drifting
    // by itself; the pointer or a finger still turns it).
    var DEFAULTS = { segments: 12, speed: 1, zoom: 1, tile: 900 };

    var css =
      '.kld{position:relative;overflow:hidden;background:#0b0716;touch-action:pan-y}' +
      '.kld-src{opacity:0}' +
      '.kld-canvas{position:absolute;inset:0;width:100%;height:100%}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

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

    // Turn the source (<img> or inline <svg>) into something drawImage can use.
    function loadSource(node, done) {
      if (node.tagName.toLowerCase() === 'svg') {
        var vb = node.viewBox && node.viewBox.baseVal, r = node.getBoundingClientRect();
        var vw = (vb && vb.width) || r.width || 300, vh = (vb && vb.height) || r.height || 150;
        var k = 1000 / Math.max(vw, vh);
        var clone = node.cloneNode(true);
        clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
        clone.setAttribute('width', Math.round(vw * k));
        clone.setAttribute('height', Math.round(vh * k));
        var im = new Image();
        im.onload = function () { done(im, im.width, im.height); };
        im.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(new XMLSerializer().serializeToString(clone));
      } else if (node.complete && node.naturalWidth) {
        done(node, node.naturalWidth, node.naturalHeight);
      } else {
        node.addEventListener('load', function () { done(node, node.naturalWidth, node.naturalHeight); });
      }
    }

    // A tile of the picture next to its mirror images, so it repeats without seams.
    function makeTile(img, iw, ih) {
      var s = DEFAULTS.tile / Math.max(iw, ih);
      var tw = Math.round(iw * s), th = Math.round(ih * s);
      var c = document.createElement('canvas');
      c.width = tw * 2;
      c.height = th * 2;
      var x = c.getContext('2d');
      for (var i = 0; i < 4; i++) {
        x.setTransform(i % 2 ? -1 : 1, 0, 0, i > 1 ? -1 : 1, i % 2 ? tw * 2 : 0, i > 1 ? th * 2 : 0);
        x.drawImage(img, 0, 0, tw, th);
      }
      return c;
    }

    function setup(el, img, iw, ih) {
      var d = el.dataset;
      var n = Math.max(4, Math.round((d.segments ? +d.segments : DEFAULTS.segments) / 2) * 2);
      var speed = d.speed !== undefined ? +d.speed : DEFAULTS.speed;
      var zoom = d.zoom !== undefined ? +d.zoom : DEFAULTS.zoom;
      var circle = d.shape === 'circle';
      var auto = d.autoplay !== 'false';
      var canvas = document.createElement('canvas');
      canvas.className = 'kld-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.appendChild(canvas);
      var ctx = canvas.getContext('2d');
      var tile = makeTile(img, iw, ih);
      var pattern = ctx.createPattern(tile, 'repeat');
      var w = 0, h = 0, dpr = 1;
      // Where in the picture the slices look, and how far they have turned.
      var pos = { x: tile.width * 0.3, y: tile.height * 0.35, a: 0 };
      var push = { x: 0, y: 0, a: 0 };

      function size() {
        dpr = Math.min(2, window.devicePixelRatio || 1);
        w = el.clientWidth;
        h = el.clientHeight;
        canvas.width = Math.max(1, Math.round(w * dpr));
        canvas.height = Math.max(1, Math.round(h * dpr));
      }

      // Fill around (x, y) with the picture, scaled, turned and shifted to the current spot.
      function paint(x, y, k, R) {
        ctx.save();
        ctx.translate(x, y);
        ctx.scale(k, k);
        ctx.rotate(pos.a);
        ctx.translate(-pos.x, -pos.y);
        ctx.fillStyle = pattern;
        ctx.fillRect(pos.x - R * 2 / k, pos.y - R * 2 / k, R * 4 / k, R * 4 / k);
        ctx.restore();
      }

      function draw() {
        if (!w || !h) return;
        var cx = w / 2, cy = h / 2;
        var R = circle ? Math.min(w, h) * 0.47 : Math.hypot(w, h) / 2 + 2;
        var wedge = (Math.PI * 2) / n;
        // Scale the picture so a slice shows a good chunk of it at any size.
        var k = (R / 420) * zoom;
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        ctx.fillStyle = '#0b0716';
        ctx.fillRect(0, 0, w, h);
        // An unmirrored layer underneath, so the hairline seams between slices pick up the right colours.
        ctx.save();
        ctx.beginPath();
        ctx.arc(cx, cy, R, 0, Math.PI * 2);
        ctx.clip();
        paint(cx, cy, k, R);
        ctx.restore();
        var gap = 0.6 / R;
        for (var i = 0; i < n; i++) {
          ctx.save();
          ctx.translate(cx, cy);
          // Every other slice is a mirror image, so the edges always meet.
          if (i % 2) { ctx.rotate((i + 1) * wedge); ctx.scale(1, -1); }
          else ctx.rotate(i * wedge);
          ctx.beginPath();
          ctx.moveTo(0, 0);
          ctx.arc(0, 0, R, -gap, wedge + gap);
          ctx.closePath();
          ctx.clip();
          paint(0, 0, k, R);
          ctx.restore();
        }
        if (circle) {
          ctx.strokeStyle = 'rgba(255,255,255,.18)';
          ctx.lineWidth = 3;
          ctx.beginPath();
          ctx.arc(cx, cy, R, 0, Math.PI * 2);
          ctx.stroke();
        }
        // A soft glow in the middle and darker edges, like light through glass.
        var g = ctx.createRadialGradient(cx, cy, 0, cx, cy, R);
        g.addColorStop(0, 'rgba(255,255,255,.12)');
        g.addColorStop(0.5, 'rgba(255,255,255,0)');
        g.addColorStop(1, 'rgba(0,0,0,.35)');
        ctx.fillStyle = g;
        ctx.beginPath();
        ctx.arc(cx, cy, R, 0, Math.PI * 2);
        ctx.fill();
      }

      size();
      draw();
      if (window.ResizeObserver) {
        new ResizeObserver(function () {
          if (el.clientWidth === w && el.clientHeight === h) return;
          size();
          draw();
        }).observe(el);
      }
      if (reduced()) return;

      // Pointer and touch nudge the picture and turn it; it eases back to drifting.
      var lastX = null, lastY = null;
      function move(e) {
        if (lastX !== null) {
          push.x += (e.clientX - lastX) * 0.6;
          push.y += (e.clientY - lastY) * 0.6;
          push.a += (e.clientX - lastX) * 0.002;
        }
        lastX = e.clientX;
        lastY = e.clientY;
      }
      el.addEventListener('pointermove', move);
      el.addEventListener('pointerdown', function (e) { lastX = e.clientX; lastY = e.clientY; });
      el.addEventListener('pointerleave', function () { lastX = lastY = null; });

      var t = 0, 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) : 16;
          last = now;
          if (auto) {
            t += dt * 0.001 * speed;
            pos.x += (Math.sin(t * 0.37) * 22 + 14) * dt * 0.001 * speed;
            pos.y += (Math.cos(t * 0.29) * 18 + 8) * dt * 0.001 * speed;
            pos.a += 0.06 * dt * 0.001 * speed;
          }
          pos.x += push.x * 0.08;
          pos.y += push.y * 0.08;
          pos.a += push.a * 0.08;
          push.x *= 0.92;
          push.y *= 0.92;
          push.a *= 0.92;
          // Keep the numbers small; the tile repeats, so wrapping is invisible.
          pos.x = ((pos.x % tile.width) + tile.width) % tile.width;
          pos.y = ((pos.y % tile.height) + tile.height) % tile.height;
          draw();
        }
      });

      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) { last = 0; timer.play(); }
        else timer.pause();
      }, { threshold: 0.1 }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-kaleidoscope]').forEach(function (el) {
        if (el._kld) return;
        var src = el.querySelector('img, svg');
        if (!src) return;
        el._kld = true;
        el.classList.add('kld');
        src.classList.add('kld-src');
        loadSource(src, function (img, iw, ih) { setup(el, img, iw, ih); });
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<figure data-kaleidoscope style="height:400px">
  <img src="flowers.jpg" alt="A garden full of flowers">
</figure>