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:
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
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];
|
||||
// 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 });
|
||||
}
|
||||
|
||||
function placeOne(tile: HTMLElement, animate: boolean) {
|
||||
if (animate && !prefersReducedMotion) {
|
||||
tile.classList.add('revealing');
|
||||
tile.addEventListener(
|
||||
'animationend',
|
||||
() => tile.classList.remove('revealing'),
|
||||
{ once: true }
|
||||
);
|
||||
}
|
||||
shortestColumn().appendChild(tile);
|
||||
// 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({});
|
||||
});
|
||||
}
|
||||
|
||||
function placeBatch(n: number, animate: boolean) {
|
||||
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;
|
||||
|
||||
@@ -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 1–2 cols fluid
|
||||
narrow 880–1104px 42rem (756px) 3 cols contained 225px
|
||||
regular 1104–1376px 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; }
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -25,6 +25,7 @@ const lastUpdated = reviews[0]?.data.date;
|
||||
subheader="Every review as a tile."
|
||||
lastUpdated={lastUpdated}
|
||||
noindex
|
||||
wide
|
||||
>
|
||||
<Masonry>
|
||||
{tiles.map((tile) => (
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user