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}
+218 -78
View File
@@ -20,11 +20,22 @@ const dataBatch = batch != null ? String(batch) : undefined;
const sources = document.querySelectorAll<HTMLDivElement>('.masonry-source'); const sources = document.querySelectorAll<HTMLDivElement>('.masonry-source');
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; 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 { function colCount(): number {
const w = window.innerWidth; const w = window.innerWidth;
if (w <= 520) return 1; if (w <= 520) return 1; // mobile
if (w <= 880) return 2; if (w <= 880) return 2; // mobile / small
return 3; 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) { for (const source of sources) {
@@ -34,9 +45,12 @@ const dataBatch = batch != null ? String(batch) : undefined;
const initialAttr = source.dataset.initial; const initialAttr = source.dataset.initial;
const batchAttr = source.dataset.batch; const batchAttr = source.dataset.batch;
const progressive = initialAttr !== undefined && batchAttr !== undefined; const progressive = initialAttr !== undefined && batchAttr !== undefined;
const initial = progressive ? Number(initialAttr) : tiles.length; const initialN = progressive ? Number(initialAttr) : tiles.length;
const batch = progressive ? Number(batchAttr) : 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'); const masonry = document.createElement('div');
masonry.className = 'masonry'; masonry.className = 'masonry';
source.parentElement?.insertBefore(masonry, source); source.parentElement?.insertBefore(masonry, source);
@@ -48,124 +62,250 @@ const dataBatch = batch != null ? String(batch) : undefined;
loadMore.textContent = 'Load more'; loadMore.textContent = 'Load more';
masonry.parentElement?.insertBefore(loadMore, masonry.nextSibling); masonry.parentElement?.insertBefore(loadMore, masonry.nextSibling);
let cols: HTMLDivElement[] = []; const stash: HTMLElement[] = tiles.slice();
let stash: HTMLElement[] = tiles.slice(); const placed: HTMLElement[] = [];
let placed: HTMLElement[] = [];
let currentCols = colCount(); let currentCols = colCount();
let flipUntil = 0; // ignore live retracks while a glide plays
let rafId = 0;
let settleTimer = 0;
function buildColumns() { // Predict the masonry's FINAL width rather than measuring it live. At the
masonry.replaceChildren(); // two tier boundaries (1104px: body max-width tweens 42rem→55.5rem over
cols = []; // 0.45s; 1376px: .masonry margin/padding tween to full-bleed) the container
for (let i = 0; i < currentCols; i++) { // is still mid-transition the instant a column-count change fires, so a
const c = document.createElement('div'); // live clientWidth read targets a transient width and the correction lands
c.className = 'masonry-col'; // as a visible snap. --max is a custom property, so reading it back returns
masonry.appendChild(c); // the declared (final) value even while the max-width property it feeds is
cols.push(c); // 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;
}
}
const cardW = cols > 0 ? (innerW - (cols - 1) * gap) / cols : innerW;
return { cols, cardW, gap, padL };
}
// 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 });
}
}
// 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';
} }
} }
function shortestColumn(): HTMLDivElement { // Run an animated layout and hold off live retracks until the glide settles.
let min = Infinity; function animatedLayout(reveal?: Set<HTMLElement>) {
let idx = 0; flipUntil = performance.now() + 560;
for (let i = 0; i < cols.length; i++) { window.clearTimeout(settleTimer);
const h = cols[i].offsetHeight; settleTimer = window.setTimeout(() => layout({}), 600);
if (h < min) { min = h; idx = i; } layout({ flip: true, reveal });
}
return cols[idx];
} }
function placeOne(tile: HTMLElement, animate: boolean) { // Coalesced retrack from resize / content-size changes. A column-count change
if (animate && !prefersReducedMotion) { // glides — even one that lands mid-glide, so it retargets seamlessly instead
tile.classList.add('revealing'); // of being dropped (the FLIP's old positions come from live
tile.addEventListener( // getBoundingClientRect(), not from `currentCols`). A same-tier width change
'animationend', // during a glide is self-inflicted by the animating tile widths, so it's
() => tile.classList.remove('revealing'), // suppressed until the glide settles.
{ once: true } function schedule() {
); if (rafId) return;
} rafId = window.requestAnimationFrame(() => {
shortestColumn().appendChild(tile); 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({});
});
} }
function placeBatch(n: number, animate: boolean) { const ro = new ResizeObserver(() => schedule());
function addBatch(n: number, reveal: boolean) {
const slice = stash.splice(0, n); const slice = stash.splice(0, n);
if (slice.length === 0) return;
const revealSet = reveal && !prefersReducedMotion ? new Set(slice) : undefined;
for (const t of slice) { for (const t of slice) {
if (revealSet) t.style.opacity = '0';
masonry.appendChild(t);
placed.push(t); placed.push(t);
placeOne(t, animate); ro.observe(t);
} }
if (revealSet) animatedLayout(revealSet);
else layout({});
} }
function rebuild() { addBatch(Math.min(initialN, stash.length), false);
const visibleCount = placed.length;
stash = placed.concat(stash);
placed = [];
buildColumns();
placeBatch(visibleCount, false);
}
buildColumns();
placeBatch(Math.min(initial, stash.length), false);
if (progressive && stash.length > 0) { if (progressive && stash.length > 0) {
loadMore.addEventListener('click', () => { loadMore.addEventListener('click', () => {
placeBatch(Math.min(batch, stash.length), true); addBatch(Math.min(batchN, stash.length), true);
if (stash.length === 0) loadMore.remove(); if (stash.length === 0) loadMore.remove();
}); });
} else { } else {
loadMore.remove(); loadMore.remove();
} }
let resizeTimer: number | undefined; window.addEventListener('resize', schedule);
window.addEventListener('resize', () => {
if (resizeTimer) window.clearTimeout(resizeTimer);
resizeTimer = window.setTimeout(() => {
const next = colCount();
if (next !== currentCols) {
currentCols = next;
rebuild();
}
}, 150);
});
} }
</script> </script>
<style is:global> <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 { .masonry-source {
column-count: 3; column-count: 4; /* regular */
column-gap: 1rem; 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: 880px) { .masonry-source { column-count: 2; } }
@media (max-width: 520px) { .masonry-source { column-count: 1; } } @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 > * { .masonry-source > * {
break-inside: avoid; break-inside: avoid;
display: block; display: block;
margin: 0 0 1rem; 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 { .masonry {
display: flex; position: relative;
gap: 1rem; /* height set by JS; eased below so content beneath the grid glides along
align-items: flex-start; 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 { .masonry > .masonry-tile {
flex: 1 1 0; position: absolute;
min-width: 0; top: 0;
display: flex; left: 0;
flex-direction: column; margin: 0;
gap: 1rem; 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 { /* During measurement/inversion we position with transitions off. */
animation: masonry-tile-in 520ms cubic-bezier(0.22, 1, 0.36, 1) both; .masonry.is-measuring > .masonry-tile {
will-change: transform, opacity; transition: none;
}
@keyframes masonry-tile-in {
from { opacity: 0; transform: translateY(22px); }
to { opacity: 1; transform: translateY(0); }
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.masonry-tile.revealing { animation: none; } .masonry,
.masonry > .masonry-tile { transition: none; }
} }
.masonry-load-more { .masonry-load-more {
display: block; display: block;
margin: 1.5rem auto 0; margin: 1.5rem auto 0;
+37 -1
View File
@@ -20,6 +20,8 @@ interface Props {
modifiedTime?: Date; modifiedTime?: Date;
articleTags?: string[]; articleTags?: string[];
jsonLd?: Record<string, unknown> | Record<string, unknown>[]; jsonLd?: Record<string, unknown> | Record<string, unknown>[];
/** Opt into the wider content tier on roomy viewports (grid/listing pages). */
wide?: boolean;
} }
const { const {
@@ -38,6 +40,7 @@ const {
modifiedTime, modifiedTime,
articleTags, articleTags,
jsonLd, jsonLd,
wide,
} = Astro.props; } = Astro.props;
const siteName = 'Unique'; const siteName = 'Unique';
const SITE_FOUNDED = new Date(2026, 4, 3); 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> <script defer src="https://static.cloudflareinsights.com/beacon.min.js" data-cf-beacon='{"token": "fc978456945c49ae90a0b7e0b891ffd2"}'></script>
</head> </head>
<body> <body class:list={[{ wide }]}>
<header class="masthead"> <header class="masthead">
<div class="masthead-row"> <div class="masthead-row">
<div class="masthead-stats"> <div class="masthead-stats">
@@ -158,8 +161,32 @@ const year = new Date().getFullYear();
--muted: #6b6b6b; --muted: #6b6b6b;
--accent: #b34700; --accent: #b34700;
--rule: #e0dbd2; --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; --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; } * { box-sizing: border-box; }
html, body { html, body {
margin: 0; margin: 0;
@@ -171,10 +198,19 @@ const year = new Date().getFullYear();
line-height: 1.55; line-height: 1.55;
-webkit-font-smoothing: antialiased; -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 { body {
max-width: var(--max); max-width: var(--max);
margin: 0 auto; margin: 0 auto;
padding: 1.5rem 1.25rem 2rem; 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 { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; } 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.`} subheader={`All ${bundles.length} themed bundles.`}
lastUpdated={lastUpdated} lastUpdated={lastUpdated}
noindex noindex
wide
> >
<Masonry> <Masonry>
{bundles.map((b) => { {bundles.map((b) => {
+1
View File
@@ -54,6 +54,7 @@ const quoteCount = finds.filter(isQuote).length;
subheader={`All ${finds.length} captured finds.`} subheader={`All ${finds.length} captured finds.`}
lastUpdated={lastUpdated} lastUpdated={lastUpdated}
noindex noindex
wide
> >
{import.meta.env.DEV && ( {import.meta.env.DEV && (
<div class="find-filter-row"> <div class="find-filter-row">
+1
View File
@@ -25,6 +25,7 @@ const lastUpdated = reviews[0]?.data.date;
subheader="Every review as a tile." subheader="Every review as a tile."
lastUpdated={lastUpdated} lastUpdated={lastUpdated}
noindex noindex
wide
> >
<Masonry> <Masonry>
{tiles.map((tile) => ( {tiles.map((tile) => (
+1
View File
@@ -93,6 +93,7 @@ const itemList = buildItemList(
lastUpdated={lastUpdated} lastUpdated={lastUpdated}
ogImage={ogImage} ogImage={ogImage}
jsonLd={[WEBSITE, ORGANIZATION, itemList]} jsonLd={[WEBSITE, ORGANIZATION, itemList]}
wide
> >
{hero && hero.kind === 'review' && ( {hero && hero.kind === 'review' && (
<a href={hero.href} class="hero"> <a href={hero.href} class="hero">