site: layout width tiers + masonry glide — column changes animate instead of snapping

Named width tiers (mobile/narrow/regular/wide) with body max-width easing
between them; grid pages opt in via BaseLayout `wide`. Masonry is now
absolute-positioned with FLIP glides on column-count changes: targets are
computed from the *final* container width (reading --max, which flips
instantly while max-width tweens), mid-glide column changes retarget
instead of being dropped, and container height eases so content below
rides along.

Claude-Session: https://claude.ai/code/session_01ADR9r5rw5NuvvnCxfYbMuc
This commit is contained in:
2026-07-11 13:42:23 -04:00
parent d51dd85a36
commit e06e7311d6
7 changed files with 260 additions and 80 deletions
+1 -1
View File
@@ -1 +1 @@
{"sessionId":"87abedef-67c7-43ce-984d-6586f084558f","pid":85715,"procStart":"Sun May 3 13:48:07 2026","acquiredAt":1777883275428}
{"sessionId":"f84073a4-0271-4bee-83d1-e6095f6a42e0","pid":80707,"procStart":"Fri Jul 3 18:24:10 2026","acquiredAt":1783106632209}
+217 -77
View File
@@ -20,11 +20,22 @@ const dataBatch = batch != null ? String(batch) : undefined;
const sources = document.querySelectorAll<HTMLDivElement>('.masonry-source');
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// 1rem in px (html font-size = 18px here) — the grid gap, kept robust to changes.
function rootRem(): number {
return parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
}
// Column count per width tier — see the tier table in BaseLayout.astro.
function colCount(): number {
const w = window.innerWidth;
if (w <= 520) return 1;
if (w <= 880) return 2;
return 3;
if (w <= 520) return 1; // mobile
if (w <= 880) return 2; // mobile / small
if (w < 1104) return 3; // narrow — 225px cards
if (w < 1376) return 4; // regular — 225px cards, 4 cols
// wide — full-width grid of ~288px (16rem) cards; round so card width stays
// near the target as columns flow in.
const gap = rootRem();
return Math.max(4, Math.round((w - 1.5 * gap) / (17 * gap)));
}
for (const source of sources) {
@@ -34,9 +45,12 @@ const dataBatch = batch != null ? String(batch) : undefined;
const initialAttr = source.dataset.initial;
const batchAttr = source.dataset.batch;
const progressive = initialAttr !== undefined && batchAttr !== undefined;
const initial = progressive ? Number(initialAttr) : tiles.length;
const batch = progressive ? Number(batchAttr) : tiles.length;
const initialN = progressive ? Number(initialAttr) : tiles.length;
const batchN = progressive ? Number(batchAttr) : tiles.length;
// Absolute-positioned masonry: tiles are placed with transform + width so a
// column-count change animates (FLIP) instead of snapping. .masonry-source is
// only the no-JS / pre-hydration fallback.
const masonry = document.createElement('div');
masonry.className = 'masonry';
source.parentElement?.insertBefore(masonry, source);
@@ -48,124 +62,250 @@ const dataBatch = batch != null ? String(batch) : undefined;
loadMore.textContent = 'Load more';
masonry.parentElement?.insertBefore(loadMore, masonry.nextSibling);
let cols: HTMLDivElement[] = [];
let stash: HTMLElement[] = tiles.slice();
let placed: HTMLElement[] = [];
const stash: HTMLElement[] = tiles.slice();
const placed: HTMLElement[] = [];
let currentCols = colCount();
let flipUntil = 0; // ignore live retracks while a glide plays
let rafId = 0;
let settleTimer = 0;
function buildColumns() {
masonry.replaceChildren();
cols = [];
for (let i = 0; i < currentCols; i++) {
const c = document.createElement('div');
c.className = 'masonry-col';
masonry.appendChild(c);
cols.push(c);
// Predict the masonry's FINAL width rather than measuring it live. At the
// two tier boundaries (1104px: body max-width tweens 42rem→55.5rem over
// 0.45s; 1376px: .masonry margin/padding tween to full-bleed) the container
// is still mid-transition the instant a column-count change fires, so a
// live clientWidth read targets a transient width and the correction lands
// as a visible snap. --max is a custom property, so reading it back returns
// the declared (final) value even while the max-width property it feeds is
// still animating — that's the escape hatch. The constants below (1.25rem
// full-bleed padding, box-sizing: border-box) mirror the tier table in
// BaseLayout.astro ("Layout width tiers"); keep them in sync with it.
function metrics() {
const gap = rootRem();
const cols = colCount();
const fullBleed =
document.body.classList.contains('wide') &&
window.matchMedia('(min-width: 86rem)').matches;
let innerW: number;
let padL: number;
if (fullBleed) {
// .masonry { padding-inline: 1.25rem } in the wide media query below.
padL = 1.25 * gap;
innerW = document.documentElement.clientWidth - 2 * padL;
} else {
const bodyCs = getComputedStyle(document.body);
const maxStr = bodyCs.getPropertyValue('--max').trim();
const maxPx = maxStr.endsWith('rem') ? parseFloat(maxStr) * gap : parseFloat(maxStr);
if (Number.isNaN(maxPx)) {
// Unparseable --max — fall back to a live (possibly mid-transition) read.
const cs = getComputedStyle(masonry);
padL = parseFloat(cs.paddingLeft) || 0;
const padR = parseFloat(cs.paddingRight) || 0;
innerW = masonry.clientWidth - padL - padR;
} else {
// Body is box-sizing: border-box; its own padding never animates.
const bodyPadL = parseFloat(bodyCs.paddingLeft) || 0;
const bodyPadR = parseFloat(bodyCs.paddingRight) || 0;
const containerW = Math.min(document.documentElement.clientWidth, maxPx);
innerW = containerW - bodyPadL - bodyPadR;
padL = 0;
}
}
function shortestColumn(): HTMLDivElement {
let min = Infinity;
let idx = 0;
for (let i = 0; i < cols.length; i++) {
const h = cols[i].offsetHeight;
if (h < min) { min = h; idx = i; }
}
return cols[idx];
const cardW = cols > 0 ? (innerW - (cols - 1) * gap) / cols : innerW;
return { cols, cardW, gap, padL };
}
function placeOne(tile: HTMLElement, animate: boolean) {
if (animate && !prefersReducedMotion) {
tile.classList.add('revealing');
tile.addEventListener(
'animationend',
() => tile.classList.remove('revealing'),
{ once: true }
);
// Position every placed tile via shortest-column packing. With flip/reveal the
// move is animated (FLIP): record old rects, jump to the new layout with
// transitions off, invert back to old, then release so CSS eases to new.
function layout(opts: { flip?: boolean; reveal?: Set<HTMLElement> } = {}) {
if (placed.length === 0) return;
const { cols, cardW, gap, padL } = metrics();
if (!(cardW > 0)) return;
const animate = (!!opts.flip || !!opts.reveal) && !prefersReducedMotion;
// First — remember where things are.
const oldPos = new Map<HTMLElement, { x: number; y: number; w: number }>();
if (animate) {
const base = masonry.getBoundingClientRect();
for (const t of placed) {
if (opts.reveal?.has(t)) continue;
const r = t.getBoundingClientRect();
oldPos.set(t, { x: r.left - base.left, y: r.top - base.top, w: r.width });
}
shortestColumn().appendChild(tile);
}
function placeBatch(n: number, animate: boolean) {
// Last — apply new widths, measure heights, pack into shortest column.
masonry.classList.add('is-measuring');
for (const t of placed) t.style.width = cardW + 'px';
const colH = new Array(cols).fill(0);
const newPos = new Map<HTMLElement, { x: number; y: number }>();
for (const t of placed) {
const h = t.offsetHeight;
let c = 0;
for (let i = 1; i < cols; i++) if (colH[i] < colH[c]) c = i;
newPos.set(t, { x: padL + c * (cardW + gap), y: colH[c] });
colH[c] += h + gap;
}
masonry.style.height = Math.max(0, Math.max(...colH) - gap) + 'px';
if (!animate) {
for (const t of placed) {
const p = newPos.get(t)!;
t.style.transform = `translate(${p.x}px, ${p.y}px)`;
t.style.opacity = '1';
}
masonry.classList.remove('is-measuring');
return;
}
// Invert — snap each tile back to where it just was (or below, for reveals).
for (const t of placed) {
const p = newPos.get(t)!;
if (opts.reveal?.has(t)) {
t.style.width = cardW + 'px';
t.style.transform = `translate(${p.x}px, ${p.y + 26}px)`;
t.style.opacity = '0';
} else if (oldPos.has(t)) {
const o = oldPos.get(t)!;
t.style.width = o.w + 'px';
t.style.transform = `translate(${o.x}px, ${o.y}px)`;
} else {
t.style.transform = `translate(${p.x}px, ${p.y}px)`;
t.style.opacity = '1';
}
}
void masonry.offsetWidth; // reflow so the inverted start is committed
// Play — release transitions and move to the real layout.
masonry.classList.remove('is-measuring');
for (const t of placed) {
const p = newPos.get(t)!;
t.style.width = cardW + 'px';
t.style.transform = `translate(${p.x}px, ${p.y}px)`;
t.style.opacity = '1';
}
}
// Run an animated layout and hold off live retracks until the glide settles.
function animatedLayout(reveal?: Set<HTMLElement>) {
flipUntil = performance.now() + 560;
window.clearTimeout(settleTimer);
settleTimer = window.setTimeout(() => layout({}), 600);
layout({ flip: true, reveal });
}
// Coalesced retrack from resize / content-size changes. A column-count change
// glides — even one that lands mid-glide, so it retargets seamlessly instead
// of being dropped (the FLIP's old positions come from live
// getBoundingClientRect(), not from `currentCols`). A same-tier width change
// during a glide is self-inflicted by the animating tile widths, so it's
// suppressed until the glide settles.
function schedule() {
if (rafId) return;
rafId = window.requestAnimationFrame(() => {
rafId = 0;
const next = colCount();
if (next !== currentCols) {
currentCols = next;
animatedLayout();
return;
}
if (performance.now() < flipUntil) return; // self-inflicted RO ticks while a glide plays
layout({});
});
}
const ro = new ResizeObserver(() => schedule());
function addBatch(n: number, reveal: boolean) {
const slice = stash.splice(0, n);
if (slice.length === 0) return;
const revealSet = reveal && !prefersReducedMotion ? new Set(slice) : undefined;
for (const t of slice) {
if (revealSet) t.style.opacity = '0';
masonry.appendChild(t);
placed.push(t);
placeOne(t, animate);
ro.observe(t);
}
if (revealSet) animatedLayout(revealSet);
else layout({});
}
function rebuild() {
const visibleCount = placed.length;
stash = placed.concat(stash);
placed = [];
buildColumns();
placeBatch(visibleCount, false);
}
buildColumns();
placeBatch(Math.min(initial, stash.length), false);
addBatch(Math.min(initialN, stash.length), false);
if (progressive && stash.length > 0) {
loadMore.addEventListener('click', () => {
placeBatch(Math.min(batch, stash.length), true);
addBatch(Math.min(batchN, stash.length), true);
if (stash.length === 0) loadMore.remove();
});
} else {
loadMore.remove();
}
let resizeTimer: number | undefined;
window.addEventListener('resize', () => {
if (resizeTimer) window.clearTimeout(resizeTimer);
resizeTimer = window.setTimeout(() => {
const next = colCount();
if (next !== currentCols) {
currentCols = next;
rebuild();
}
}, 150);
});
window.addEventListener('resize', schedule);
}
</script>
<style is:global>
/* No-JS fallback: column-count masonry. JS replaces this with .masonry. */
/* No-JS / pre-hydration fallback: column-count masonry. JS replaces the
.masonry-source with an absolute-positioned .masonry (below). */
.masonry-source {
column-count: 3;
column-count: 4; /* regular */
column-gap: 1rem;
transition: margin 0.45s cubic-bezier(0.22, 1, 0.36, 1),
padding 0.45s cubic-bezier(0.22, 1, 0.36, 1);
}
@media (max-width: 1103px) { .masonry-source { column-count: 3; } } /* narrow */
@media (max-width: 880px) { .masonry-source { column-count: 2; } }
@media (max-width: 520px) { .masonry-source { column-count: 1; } }
/* wide — the grid breaks out to full-bleed while the chrome stays capped
at 55.5rem; as many ~288px columns as fit the full browser width. */
@media (min-width: 86rem) {
body.wide .masonry-source,
body.wide .masonry {
margin-inline: calc(50% - 50vw);
padding-inline: 1.25rem;
}
.masonry-source { column-count: initial; column-width: 288px; }
}
.masonry-source > * {
break-inside: avoid;
display: block;
margin: 0 0 1rem;
}
/* JS-built masonry: real columns, never reflow placed tiles. */
/* JS masonry: tiles are absolutely positioned; transform + width animate so a
column-count change eases each card from its old slot to its new one. */
.masonry {
display: flex;
gap: 1rem;
align-items: flex-start;
position: relative;
/* height set by JS; eased below so content beneath the grid glides along
with it. Also eases the full-bleed break-out (and its return). */
transition: margin 0.45s cubic-bezier(0.22, 1, 0.36, 1),
padding 0.45s cubic-bezier(0.22, 1, 0.36, 1),
height 0.5s cubic-bezier(0.22, 1, 0.36, 1);
}
.masonry-col {
flex: 1 1 0;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1rem;
.masonry > .masonry-tile {
position: absolute;
top: 0;
left: 0;
margin: 0;
transition: transform 0.5s cubic-bezier(0.22, 1, 0.36, 1),
width 0.5s cubic-bezier(0.22, 1, 0.36, 1),
opacity 0.42s ease;
will-change: transform, width;
}
.masonry-tile.revealing {
animation: masonry-tile-in 520ms cubic-bezier(0.22, 1, 0.36, 1) both;
will-change: transform, opacity;
}
@keyframes masonry-tile-in {
from { opacity: 0; transform: translateY(22px); }
to { opacity: 1; transform: translateY(0); }
/* During measurement/inversion we position with transitions off. */
.masonry.is-measuring > .masonry-tile {
transition: none;
}
@media (prefers-reduced-motion: reduce) {
.masonry-tile.revealing { animation: none; }
.masonry,
.masonry > .masonry-tile { transition: none; }
}
.masonry-load-more {
display: block;
margin: 1.5rem auto 0;
+37 -1
View File
@@ -20,6 +20,8 @@ interface Props {
modifiedTime?: Date;
articleTags?: string[];
jsonLd?: Record<string, unknown> | Record<string, unknown>[];
/** Opt into the wider content tier on roomy viewports (grid/listing pages). */
wide?: boolean;
}
const {
@@ -38,6 +40,7 @@ const {
modifiedTime,
articleTags,
jsonLd,
wide,
} = Astro.props;
const siteName = 'Unique';
const SITE_FOUNDED = new Date(2026, 4, 3);
@@ -77,7 +80,7 @@ const year = new Date().getFullYear();
/>
<script defer src="https://static.cloudflareinsights.com/beacon.min.js" data-cf-beacon='{"token": "fc978456945c49ae90a0b7e0b891ffd2"}'></script>
</head>
<body>
<body class:list={[{ wide }]}>
<header class="masthead">
<div class="masthead-row">
<div class="masthead-stats">
@@ -158,8 +161,32 @@ const year = new Date().getFullYear();
--muted: #6b6b6b;
--accent: #b34700;
--rule: #e0dbd2;
/* Content column width. Reading pages keep this tight ("narrow") for a
comfortable line length; grid/listing pages opt into wider tiers via
<body class="wide"> (BaseLayout `wide` prop). See the tier table below. */
--max: 42rem;
}
/* ── Layout width tiers ────────────────────────────────────────────────
Named steps, shared with the masonry column logic (Masonry.astro).
Element rem = 18px (html font-size), so --max in rem renders ×18;
media-query breakpoints use the browser default (1rem = 16px).
tier viewport (mq) chrome (--max) grid card
mobile ≤ 880px 42rem / fluid 12 cols fluid
narrow 8801104px 42rem (756px) 3 cols contained 225px
regular 11041376px 55.5rem (999px) 4 cols contained 225px (= narrow)
wide ≥ 1376px 55.5rem (999px) fluid, full-bleed ~288px (≈ old 3-col)
• Chrome (masthead, hero, footer) is capped at the regular 55.5rem in
BOTH regular and wide — it never moves between them. Only the card grid
breaks out to full width in wide (the full-bleed rule is in Masonry.astro).
• regular = 4·225 + 3·gap(18) + 2·pad(22.5) = 999px, so its 4 columns
match narrow's 225px card exactly.
• wide engages ~10% past a 4-col grid of 288px cards
(4·288 + 3·18 + 2·22.5 = 1251px → ×1.10 ≈ 1376px = 86rem), then fills
the full browser width with as many ~288px cards as fit.
Reading pages omit `wide`, so they stay at narrow (42rem) at every size. */
@media (min-width: 69rem) { body.wide { --max: 55.5rem; } } /* regular + wide chrome */
* { box-sizing: border-box; }
html, body {
margin: 0;
@@ -171,10 +198,19 @@ const year = new Date().getFullYear();
line-height: 1.55;
-webkit-font-smoothing: antialiased;
}
/* Clip at the viewport (not body — the wide grid breaks out past body) so a
card mid-glide from a wider layout never flashes a horizontal scrollbar. */
html { overflow-x: clip; }
body {
max-width: var(--max);
margin: 0 auto;
padding: 1.5rem 1.25rem 2rem;
/* Glide between width tiers instead of snapping — the hero and contained
cards ride inside body, so they gently resize along with the column. */
transition: max-width 0.45s cubic-bezier(0.22, 1, 0.36, 1);
}
@media (prefers-reduced-motion: reduce) {
body { transition: none; }
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
+1
View File
@@ -24,6 +24,7 @@ const fmt = new Intl.DateTimeFormat('en-US', {
subheader={`All ${bundles.length} themed bundles.`}
lastUpdated={lastUpdated}
noindex
wide
>
<Masonry>
{bundles.map((b) => {
+1
View File
@@ -54,6 +54,7 @@ const quoteCount = finds.filter(isQuote).length;
subheader={`All ${finds.length} captured finds.`}
lastUpdated={lastUpdated}
noindex
wide
>
{import.meta.env.DEV && (
<div class="find-filter-row">
+1
View File
@@ -25,6 +25,7 @@ const lastUpdated = reviews[0]?.data.date;
subheader="Every review as a tile."
lastUpdated={lastUpdated}
noindex
wide
>
<Masonry>
{tiles.map((tile) => (
+1
View File
@@ -93,6 +93,7 @@ const itemList = buildItemList(
lastUpdated={lastUpdated}
ogImage={ogImage}
jsonLd={[WEBSITE, ORGANIZATION, itemList]}
wide
>
{hero && hero.kind === 'review' && (
<a href={hero.href} class="hero">