Polaroid Scatter
Photos in white instant-print frames drop onto the page one after another, landing at odd angles and sliding to a stop like prints tossed on a table. Tap one to pick it up for a closer look.
How to use it
- Paste the snippet just before
</head>. - Add
data-polaroid-scatterto a container of photos (each a<figure>with an image and an optional caption). Give the container a height and the photos a width.
Options
Add any of these attributes to the same element.
data-angle | The most a print tilts, either way. Default 14°. |
data-stagger | Time between drops. Default 220 ms. |
data-loop | Gather the prints up and drop them again somewhere new. |
data-hold | How long the prints rest before looping. Default 2600 ms. |
data-frame="false" | Leave out the white frame, for photos you’ve styled yourself. |
The prints spread over the whole container without going past its edges, in a new arrangement every time. Each one is a toggle button: tap, click or press Enter to lift it into the middle and straighten it. From code, animacious.polaroidScatter(element) drops them again.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Polaroid Scatter: photos drop onto the container one by one and land at
// odd angles, sliding to a stop like prints tossed on a table. Tap a print
// to pick it up and straighten it; tap again to put it back down.
// Usage:
// <div data-polaroid-scatter style="height: 400px">
// <figure><img src="beach.jpg" alt="Beach"><figcaption>Beach</figcaption></figure>
// <figure>…</figure>
// </div>
// Each child is a print; give the prints a width. The script adds the white
// frame (unless data-frame="false") and scatters them over the container.
// Optional: data-angle (most a print tilts, degrees, default 14),
// data-stagger (ms between drops, default 220), data-loop (gather them up
// and drop them again somewhere new), data-hold (ms the prints rest before
// looping, default 2600).
// Script use: animacious.polaroidScatter(element) drops them again.
var DEFAULTS = { angle: 14, stagger: 220, hold: 2600 };
var REST = '0 3px 8px rgba(15,15,30,.22)';
var AIR = '0 34px 40px rgba(15,15,30,.12)';
var css =
'[data-polaroid-scatter]{position:relative}' +
'[data-polaroid-scatter]>*{position:absolute;left:0;top:0;margin:0;opacity:0;cursor:pointer;-webkit-tap-highlight-color:transparent}' +
'.plr-frame{padding:8px 8px 0;background:#fff;border-radius:3px;box-shadow:' + REST + '}' +
'.plr-frame>img,.plr-frame>svg{display:block;width:100%;aspect-ratio:1;object-fit:cover}' +
'.plr-frame>figcaption{padding:7px 2px 9px;font:600 14px/1.2 "Segoe Print","Bradley Hand","Comic Sans MS",cursive;color:#3f3f46;text-align:center;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}' +
'.plr-frame:not(:has(figcaption)){padding-bottom:30px}' +
'[data-polaroid-scatter]>:focus-visible{outline:3px solid #a855f7;outline-offset:3px}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
var rnd = function (a, b) { return a + Math.random() * (b - a); };
function setup(el) {
var d = el.dataset;
var prints = Array.from(el.children);
var n = prints.length;
if (!n) return;
var angle = d.angle !== undefined ? +d.angle : DEFAULTS.angle;
var stagger = d.stagger !== undefined ? +d.stagger : DEFAULTS.stagger;
var hold = +d.hold || DEFAULTS.hold;
var inThumb = !!el.closest('[aria-hidden="true"]');
var spots = [], lifted = null, z = 1, timer = null, playing = false;
prints.forEach(function (p) {
if (d.frame !== 'false') p.classList.add('plr-frame');
if (!inThumb) {
p.tabIndex = 0;
p.setAttribute('role', 'button');
p.setAttribute('aria-pressed', 'false');
}
});
// A fresh scatter: a jittered grid of spots in a random order, each with
// a tilt. Stored as fractions so a resize keeps the same arrangement.
function shuffle() {
var W = el.clientWidth || 1, H = el.clientHeight || 1;
var w = prints[0].offsetWidth || 120, h = prints[0].offsetHeight || 150;
var cols = Math.max(1, Math.min(n, Math.round(Math.sqrt(n * (W / w) / Math.max(0.5, H / h)))));
var rows = Math.ceil(n / cols);
var order = prints.map(function (_, i) { return i; }).sort(function () { return Math.random() - 0.5; });
spots = order.map(function (cell) {
var c = cell % cols, r = Math.floor(cell / cols);
var inRow = r === rows - 1 ? n - cols * (rows - 1) : cols; // centre a short last row
var shift = (cols - inRow) / 2;
return {
fx: cols === 1 ? 0.5 : Math.max(0, Math.min(1, (c + shift + rnd(-0.22, 0.22)) / (cols - 1))),
fy: rows === 1 ? 0.5 : Math.max(0, Math.min(1, (r + rnd(-0.18, 0.18)) / (rows - 1))),
rot: rnd(-angle, angle)
};
});
}
// Where a print's top-left goes for its spot, kept inside the container.
function place(i) {
var p = prints[i], s = spots[i];
var W = el.clientWidth, H = el.clientHeight, w = p.offsetWidth, h = p.offsetHeight;
var m = 10;
return {
x: m + s.fx * Math.max(0, W - w - 2 * m) + (W - w - 2 * m < 0 ? (W - w) / 2 - m : 0),
y: m + s.fy * Math.max(0, H - h - 2 * m) + (H - h - 2 * m < 0 ? (H - h) / 2 - m : 0),
rotate: s.rot
};
}
function settle() {
prints.forEach(function (p, i) {
if (p === lifted) return;
var to = place(i);
anime.utils.set(p, { x: to.x, y: to.y, rotate: to.rotate });
});
if (lifted) anime.utils.set(lifted, liftPose());
}
function drop() {
playing = true;
lifted = null;
shuffle();
var done = 0;
prints.forEach(function (p, i) {
var to = place(i);
p.style.zIndex = ++z;
if (!inThumb) p.setAttribute('aria-pressed', 'false');
if (reduced()) {
anime.utils.set(p, { x: to.x, y: to.y, rotate: to.rotate, scale: 1, opacity: 1 });
return;
}
// Falls towards the table (growing smaller as it drops), then slides to a stop.
var dx = rnd(-30, 30), dy = rnd(-24, 24), spin = rnd(-25, 25);
anime.utils.set(p, { x: to.x - dx, y: to.y - dy - 30, rotate: to.rotate - spin, scale: 1.7, opacity: 0, boxShadow: AIR });
var tl = anime.createTimeline({ delay: i * stagger, onComplete: function () { if (++done === n) rest(); } });
tl.add(p, { scale: 1, opacity: { to: 1, duration: 160 }, y: to.y - dy * 0.3, x: to.x - dx * 0.3, rotate: to.rotate - spin * 0.3, boxShadow: REST, duration: 420, ease: 'in(2)' })
.add(p, { x: to.x, y: to.y, rotate: to.rotate, duration: 520, ease: 'out(3)' });
});
if (reduced()) rest();
}
function rest() {
playing = false;
if (d.loop === undefined || reduced()) return;
clearTimeout(timer);
timer = setTimeout(gather, hold);
}
// Lift the prints off the table, last dropped first, then drop again.
function gather() {
if (lifted || !visible || el.closest('[hidden]')) { timer = setTimeout(gather, 1200); return; }
playing = true;
var left = n;
prints.slice().reverse().forEach(function (p, k) {
anime.animate(p, {
scale: 1.4, opacity: 0, y: '-=20', rotate: '+=' + rnd(-12, 12).toFixed(1), boxShadow: AIR,
duration: 380, delay: k * 70, ease: 'in(2)',
onComplete: function () { if (--left === 0) setTimeout(drop, 250); }
});
});
}
function liftPose() {
var w = lifted.offsetWidth, h = lifted.offsetHeight;
return { x: (el.clientWidth - w) / 2, y: (el.clientHeight - h) / 2, rotate: 0, scale: Math.min(1.25, (el.clientHeight - 16) / h) };
}
// Pick a print up into the middle, or put it back.
function toggle(p) {
if (playing) return;
clearTimeout(timer);
var ms = reduced() ? 0 : 450;
if (lifted && lifted !== p) putBack(lifted, ms);
if (lifted === p) { putBack(p, ms); lifted = null; rest(); return; }
lifted = p;
p.style.zIndex = ++z;
p.setAttribute('aria-pressed', 'true');
var to = liftPose();
to.boxShadow = '0 24px 44px rgba(15,15,30,.28)';
to.duration = ms;
to.ease = 'out(3)';
anime.animate(p, to);
}
function putBack(p, ms) {
var to = place(prints.indexOf(p));
p.setAttribute('aria-pressed', 'false');
anime.animate(p, { x: to.x, y: to.y, rotate: to.rotate, scale: 1, boxShadow: REST, duration: ms, ease: 'out(3)' });
}
if (!inThumb) {
prints.forEach(function (p) {
p.addEventListener('click', function () { toggle(p); });
p.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(p); }
if (e.key === 'Escape' && lifted === p) toggle(p);
});
});
}
el._plr = function () { clearTimeout(timer); anime.utils.remove(prints); drop(); };
var visible = false, started = false;
shuffle();
new IntersectionObserver(function (entries) {
visible = entries[0].isIntersecting;
if (visible && !started) { started = true; drop(); }
}, { threshold: 0.2 }).observe(el);
if (window.ResizeObserver) new ResizeObserver(function () { if (started && !playing) settle(); }).observe(el);
}
function init() {
document.querySelectorAll('[data-polaroid-scatter]').forEach(function (el) {
if (el._plr) return;
el._plr = true;
setup(el);
});
}
window.animacious = window.animacious || {};
window.animacious.polaroidScatter = function (el) { if (el && typeof el._plr === 'function') el._plr(); };
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-polaroid-scatter style="height: 400px">
<figure style="width: 150px">
<img src="beach.jpg" alt="The beach">
<figcaption>Beach day</figcaption>
</figure>
<figure style="width: 150px">…</figure>
<figure style="width: 150px">…</figure>
</div>