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
- Paste the snippet just before
</head>. - Wrap an image in an element with
data-kaleidoscopeand give the wrapper a size. - 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-segments | How many slices, an even number. Default 12. |
data-speed | How fast it drifts and turns. Default 1. |
data-zoom | How far into the picture each slice looks. Default 1; higher shows less of it, bigger. |
data-shape | fill (default) fills the box; circle shows a round eyepiece. |
data-autoplay | false 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.
<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><figure data-kaleidoscope style="height:400px">
<img src="flowers.jpg" alt="A garden full of flowers">
</figure>