Masonry Reflow
A masonry grid of different-height items with filter buttons. Pick a filter and the items that stay glide to their new spots, the rest shrink away and newcomers pop in where they belong, so the grid repacks itself in one smooth move.
Night Drive
IdeasSmall things, done well, every day.
Bloom
To doPack the car. Water the plants. Leave early.
Tides
QuoteMake it simple, but significant.
Neon
How to use it
- Paste the snippet just before
</head>. - Add
data-masonry-reflowto a container of items, and list each item’s tags indata-tags. - Put buttons with
data-filter="tag"(and one withdata-filter="all") anywhere in the grid’s parent.
Options
Add any of these attributes to the same element.
data-columns | A fixed number of columns. Default: as many as fit. |
data-min | Smallest column width when choosing how many fit. Default 160 px. |
data-gap | Space between items. Default 12 px. |
data-duration | Time for each reflow. Default 650 ms. |
data-autoplay | Milliseconds: step through the filters until the visitor picks one. |
The chosen button gets aria-pressed="true" and an is-active class to style. It repacks when the grid changes width or an image loads. From code: animacious.masonry(grid, 'photo').
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Masonry Reflow: a masonry grid (items of different heights packed into
// columns) where choosing a filter makes the items that stay glide to their
// new spots, while the others shrink away and newcomers pop in.
// Usage:
// <div>
// <button data-filter="all">All</button>
// <button data-filter="photo">Photos</button>
// <button data-filter="music">Music</button>
// </div>
// <div data-masonry-reflow>
// <figure data-tags="photo">…</figure>
// <figure data-tags="music photo">…</figure>
// </div>
// Filter buttons can sit anywhere in the grid's parent element. Items list
// their tags in data-tags. Optional: data-columns (fixed number of
// columns), data-min (smallest column width in px when choosing the number
// of columns, default 160), data-gap (px, default 12), data-duration (ms,
// default 650), data-autoplay (ms: step through the filters until someone
// picks one). From code: animacious.masonry(grid, 'photo').
var DEFAULTS = { min: 160, gap: 12, duration: 650 };
var css =
'[data-masonry-reflow]{position:relative}' +
'[data-masonry-reflow]>*{position:absolute!important;top:0;left:0;margin:0!important;box-sizing:border-box}' +
'[data-masonry-reflow]>[hidden]{display:none!important}' +
'[data-masonry-reflow]>.mrf-out{pointer-events:none}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function setup(el) {
var d = el.dataset;
var items = Array.from(el.children);
var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var gap = d.gap !== undefined ? +d.gap : DEFAULTS.gap;
var duration = +d.duration || DEFAULTS.duration;
var scope = el.parentElement || document;
var buttons = Array.from(scope.querySelectorAll('[data-filter]')).filter(function (b) { return !el.contains(b); });
var filter = 'all', spots = new Map(), stopped = false, timer = null, width = 0;
function shows(item, f) {
return f === 'all' || f === '*' || (' ' + (item.getAttribute('data-tags') || '') + ' ').indexOf(' ' + f + ' ') > -1;
}
// Shortest-column packing: each item goes under the shortest column so far.
function pack(f) {
var w = el.clientWidth;
var cols = +d.columns || Math.max(1, Math.floor((w + gap) / ((+d.min || DEFAULTS.min) + gap)));
var cw = (w - gap * (cols - 1)) / cols;
var heights = [];
for (var c = 0; c < cols; c++) heights.push(0);
var out = new Map();
items.forEach(function (it) {
it.style.width = cw + 'px';
if (!shows(it, f)) return;
it.hidden = false;
var col = heights.indexOf(Math.min.apply(null, heights));
out.set(it, { x: col * (cw + gap), y: heights[col] });
heights[col] += it.offsetHeight + gap;
});
return { spots: out, height: Math.max(0, Math.max.apply(null, heights) - gap) };
}
function apply(f, animate) {
filter = f;
buttons.forEach(function (b) {
var on = b.getAttribute('data-filter') === f;
b.classList.toggle('is-active', on);
if (b.tagName === 'BUTTON') b.setAttribute('aria-pressed', on);
});
var next = pack(f);
var moving = 0;
items.forEach(function (it) {
var to = next.spots.get(it), was = spots.get(it);
if (to) {
it.hidden = false;
it.classList.remove('mrf-out');
it.removeAttribute('aria-hidden');
}
if (!animate || reduced) {
if (to) anime.utils.set(it, { x: to.x, y: to.y, scale: 1, opacity: 1 });
else it.hidden = true;
return;
}
if (to && was) {
// Stays: glide over, a beat after the ones before it.
anime.animate(it, { x: to.x, y: to.y, scale: 1, opacity: 1, duration: duration, ease: 'inOut(3)', delay: moving++ * 18 });
} else if (to) {
// Arrives: pops in where it belongs.
anime.utils.set(it, { x: to.x, y: to.y, scale: 0.6, opacity: 0 });
anime.animate(it, { scale: 1, opacity: 1, duration: duration * 0.8, ease: 'outBack(1.4)', delay: duration * 0.35 + moving++ * 18 });
} else if (was) {
// Leaves: shrinks away where it is, then gets out of the way.
it.classList.add('mrf-out');
it.setAttribute('aria-hidden', 'true');
anime.animate(it, {
scale: 0.6, opacity: 0, duration: duration * 0.5, ease: 'in(2)',
onComplete: function () { if (!spots.get(it)) it.hidden = true; }
});
}
});
spots = next.spots;
if (animate && !reduced) anime.animate(el, { height: next.height, duration: duration, ease: 'inOut(3)' });
else el.style.height = next.height + 'px';
}
function choose(f) {
if (f === filter) return;
apply(f, true);
}
buttons.forEach(function (b) {
b.addEventListener('click', function () { stopped = true; choose(b.getAttribute('data-filter')); });
});
el._mrf = choose;
apply(filter, false);
// Keep the packing right when the width changes or images load late.
if (window.ResizeObserver) new ResizeObserver(function () {
if (el.clientWidth === width) return;
width = el.clientWidth;
apply(filter, false);
}).observe(el);
el.querySelectorAll('img').forEach(function (img) {
if (!img.complete) img.addEventListener('load', function () { apply(filter, false); });
});
if (d.autoplay !== undefined && d.autoplay !== 'false' && !reduced && buttons.length) {
var every = +d.autoplay || 2200;
new IntersectionObserver(function (entries) {
clearInterval(timer);
if (!entries[0].isIntersecting || stopped) return;
timer = setInterval(function () {
if (stopped || el.closest('[hidden]')) return;
var i = buttons.findIndex(function (b) { return b.getAttribute('data-filter') === filter; });
choose(buttons[(i + 1) % buttons.length].getAttribute('data-filter'));
}, every);
}).observe(el);
}
}
window.animacious = window.animacious || {};
window.animacious.masonry = function (el, f) { if (el && el._mrf) el._mrf(f || 'all'); };
function init() {
document.querySelectorAll('[data-masonry-reflow]').forEach(function (el) {
if (el._mrfOn) return;
el._mrfOn = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div>
<div>
<button data-filter="all">All</button>
<button data-filter="photo">Photos</button>
<button data-filter="music">Music</button>
</div>
<div data-masonry-reflow>
<figure data-tags="photo"><img src="one.jpg" alt="…"></figure>
<figure data-tags="music"><img src="two.jpg" alt="…"></figure>
<figure data-tags="photo music"><img src="three.jpg" alt="…"></figure>
</div>
</div>