site: motion & delight pass — media fades in, cards lift, grids ripple

- Images/videos fade in on load instead of popping (cached media renders
  instantly; nothing is hidden without JS)
- Cross-document view transitions: soft cross-fade between pages where
  supported
- Masonry reveals ripple: initial grid and "Load more" batches stagger in
  per-tile instead of blinking on at once
- Card hover: gentle -2px lift + slow image zoom on hero, home/grid tiles,
  and find cards; find-card lift switched to `translate` so it composes
  with the masonry's inline transform (it was silently dead before), and
  its transition list unified with .masonry-tile's to end their cascade tie
- Tactile press/lift on "Load more" and the external CTA button
- Accent-tinted ::selection, visible :focus-visible rings, smooth anchor
  scrolling; global prefers-reduced-motion catch-all disables it all

Claude-Session: https://claude.ai/code/session_01ADR9r5rw5NuvvnCxfYbMuc
This commit is contained in:
2026-07-11 15:20:47 -04:00
parent e06e7311d6
commit a0ea9c9690
5 changed files with 119 additions and 13 deletions
+5 -1
View File
@@ -80,10 +80,12 @@ function visualFor(entry: Entry): Visual | undefined {
border: 2px solid var(--rule);
background: var(--bg);
color: inherit;
transition: box-shadow 0.18s ease, border-color 0.18s ease;
transition: box-shadow 0.18s ease, border-color 0.18s ease,
translate 0.3s cubic-bezier(0.22, 1, 0.36, 1);
}
.tile:hover {
text-decoration: none;
translate: 0 -2px;
border-color: color-mix(in srgb, var(--accent) 50%, var(--rule));
box-shadow: 0 0 20px -4px color-mix(in srgb, var(--accent) 30%, transparent);
}
@@ -94,7 +96,9 @@ function visualFor(entry: Entry): Visual | undefined {
width: 100%;
height: auto;
background: var(--rule);
transition: transform 0.5s cubic-bezier(0.22, 1, 0.36, 1);
}
.tile:hover .media { transform: scale(1.03); }
.text {
padding: 0.85rem 1rem 1rem;
+12 -2
View File
@@ -186,10 +186,18 @@ function host(url: string) {
border: 1px solid var(--rule);
background: var(--bg);
overflow: hidden;
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
/* The hover lift uses `translate` (not `transform`) so it composes with the
inline transform the masonry uses to position tiles instead of losing to it.
The card is itself a masonry tile, so this list must stay the same union of
properties as .masonry > .masonry-tile (Masonry.astro) — the two rules tie
on specificity and either may win the cascade. */
transition: translate 0.25s ease, box-shadow 0.18s ease, border-color 0.18s ease,
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;
}
.find-card:hover {
transform: translateY(-2px);
translate: 0 -2px;
border-color: color-mix(in srgb, var(--accent) 40%, var(--rule));
box-shadow: 0 8px 24px -10px rgba(0, 0, 0, 0.18);
}
@@ -201,7 +209,9 @@ function host(url: string) {
height: auto;
background: var(--card-fill);
border-bottom: 1px solid var(--rule);
transition: transform 0.5s cubic-bezier(0.22, 1, 0.36, 1);
}
.find-card:hover .media { transform: scale(1.03); }
.content {
padding: 0.95rem 1.05rem 1rem;
+34 -6
View File
@@ -20,6 +20,10 @@ const dataBatch = batch != null ? String(batch) : undefined;
const sources = document.querySelectorAll<HTMLDivElement>('.masonry-source');
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// Per-tile reveal stagger (ms) and its cap, so big batches don't drag on.
const STAGGER_STEP = 36;
const STAGGER_CAP = 300;
// 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;
@@ -154,6 +158,7 @@ const dataBatch = batch != null ? String(batch) : undefined;
if (!animate) {
for (const t of placed) {
const p = newPos.get(t)!;
t.style.transitionDelay = '';
t.style.transform = `translate(${p.x}px, ${p.y}px)`;
t.style.opacity = '1';
}
@@ -179,10 +184,19 @@ const dataBatch = batch != null ? String(batch) : undefined;
}
void masonry.offsetWidth; // reflow so the inverted start is committed
// Play — release transitions and move to the real layout.
// Play — release transitions and move to the real layout. Revealed tiles
// cascade with a small per-tile delay so a batch ripples in instead of
// blinking on all at once; everything else moves immediately.
masonry.classList.remove('is-measuring');
let revealIdx = 0;
for (const t of placed) {
const p = newPos.get(t)!;
if (opts.reveal?.has(t)) {
t.style.transitionDelay = Math.min(revealIdx * STAGGER_STEP, STAGGER_CAP) + 'ms';
revealIdx += 1;
} else {
t.style.transitionDelay = '';
}
t.style.width = cardW + 'px';
t.style.transform = `translate(${p.x}px, ${p.y}px)`;
t.style.opacity = '1';
@@ -190,10 +204,12 @@ const dataBatch = batch != null ? String(batch) : undefined;
}
// Run an animated layout and hold off live retracks until the glide settles.
// Reveal staggering stretches the glide, so the settle window stretches too.
function animatedLayout(reveal?: Set<HTMLElement>) {
flipUntil = performance.now() + 560;
const stagger = reveal ? Math.min(reveal.size * STAGGER_STEP, STAGGER_CAP) : 0;
flipUntil = performance.now() + 560 + stagger;
window.clearTimeout(settleTimer);
settleTimer = window.setTimeout(() => layout({}), 600);
settleTimer = window.setTimeout(() => layout({}), 600 + stagger);
layout({ flip: true, reveal });
}
@@ -234,7 +250,8 @@ const dataBatch = batch != null ? String(batch) : undefined;
else layout({});
}
addBatch(Math.min(initialN, stash.length), false);
// The first batch reveals too — a gentle staggered rise on page load.
addBatch(Math.min(initialN, stash.length), true);
if (progressive && stash.length > 0) {
loadMore.addEventListener('click', () => {
@@ -292,9 +309,15 @@ const dataBatch = batch != null ? String(batch) : undefined;
top: 0;
left: 0;
margin: 0;
/* Same union of properties as .find-card (FindCard.astro) — a find card is
itself a masonry tile and the two transition shorthands tie on specificity,
so they must agree for both the glide and the hover lift to survive. */
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;
opacity 0.42s ease,
translate 0.25s ease,
box-shadow 0.18s ease,
border-color 0.18s ease;
will-change: transform, width;
}
/* During measurement/inversion we position with transitions off. */
@@ -319,10 +342,15 @@ const dataBatch = batch != null ? String(batch) : undefined;
border: 1px solid var(--rule);
border-radius: 999px;
cursor: pointer;
transition: color 0.18s ease, border-color 0.18s ease;
transition: color 0.18s ease, border-color 0.18s ease, translate 0.15s ease, transform 0.15s ease;
}
.masonry-load-more:hover {
color: var(--accent);
border-color: color-mix(in srgb, var(--accent) 50%, var(--rule));
translate: 0 -1px;
}
.masonry-load-more:active {
translate: 0 0;
transform: scale(0.96);
}
</style>
+58 -2
View File
@@ -212,9 +212,35 @@ const year = new Date().getFullYear();
@media (prefers-reduced-motion: reduce) {
body { transition: none; }
}
a { color: var(--accent); text-decoration: none; }
a { color: var(--accent); text-decoration: none; transition: color 0.15s ease; }
a:hover { text-decoration: underline; }
/* Cross-document view transitions — a soft cross-fade between pages in
browsers that support it; everyone else just navigates. */
@view-transition { navigation: auto; }
::selection { background: color-mix(in srgb, var(--accent) 24%, var(--bg)); }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
html { scroll-behavior: smooth; }
/* Media fade-in: the script at the foot of <body> tags not-yet-loaded
images/videos with .fx-fade and flips .fx-in when each finishes, so
media eases in instead of popping. Cached media never gets the class
(no flash on repeat views); without JS nothing is ever hidden. */
.fx-fade { opacity: 0; }
.fx-fade.fx-in { opacity: 1; transition: opacity 0.55s ease; }
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
@view-transition { navigation: none; }
*, *::before, *::after {
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
}
}
/* Masthead */
.masthead {
margin-bottom: 2rem;
@@ -420,7 +446,16 @@ const year = new Date().getFullYear();
font-weight: 600;
font-size: 0.95rem;
}
.external a:hover { text-decoration: none; opacity: 0.9; }
.external a {
transition: opacity 0.18s ease, translate 0.18s ease, box-shadow 0.18s ease;
}
.external a:hover {
text-decoration: none;
opacity: 0.92;
translate: 0 -1px;
box-shadow: 0 6px 16px -8px color-mix(in srgb, var(--accent) 70%, transparent);
}
.external a:active { translate: 0 0; }
.external .aff-badge {
display: inline-block;
margin-left: 0.6rem;
@@ -519,6 +554,27 @@ const year = new Date().getFullYear();
.colophon dt:first-child { margin-top: 0; }
}
</style>
<script>
// Fade media in on load instead of popping — see .fx-fade in the global
// styles. Only media still in flight is tagged, so cached images render
// instantly with no flash.
if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
for (const img of document.querySelectorAll('img')) {
if (img.complete) continue;
const reveal = () => img.classList.add('fx-in');
img.classList.add('fx-fade');
img.addEventListener('load', reveal, { once: true });
img.addEventListener('error', reveal, { once: true });
}
for (const video of document.querySelectorAll('video')) {
if (video.readyState >= 2) continue;
const reveal = () => video.classList.add('fx-in');
video.classList.add('fx-fade');
video.addEventListener('loadeddata', reveal, { once: true });
video.addEventListener('error', reveal, { once: true });
}
}
</script>
{import.meta.env.DEV && <EditConfirm />}
</body>
</html>
+10 -2
View File
@@ -184,10 +184,12 @@ const itemList = buildItemList(
background: var(--bg);
color: inherit;
margin-bottom: 2.5rem;
transition: box-shadow 0.18s ease, border-color 0.18s ease;
transition: box-shadow 0.18s ease, border-color 0.18s ease,
translate 0.3s cubic-bezier(0.22, 1, 0.36, 1);
}
.hero:hover {
text-decoration: none;
translate: 0 -2px;
border-color: color-mix(in srgb, var(--accent) 50%, var(--rule));
box-shadow: 0 0 32px -4px color-mix(in srgb, var(--accent) 35%, transparent);
}
@@ -200,7 +202,9 @@ const itemList = buildItemList(
max-height: 62vh;
object-fit: cover;
background: var(--rule);
transition: transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
}
.hero:hover .hero-media { transform: scale(1.015); }
.hero-text {
padding: 1.6rem 1.75rem 1.75rem;
display: flex;
@@ -235,10 +239,12 @@ const itemList = buildItemList(
border: 2px solid var(--rule);
background: var(--bg);
color: inherit;
transition: box-shadow 0.18s ease, border-color 0.18s ease;
transition: box-shadow 0.18s ease, border-color 0.18s ease,
translate 0.3s cubic-bezier(0.22, 1, 0.36, 1);
}
.tile:hover {
text-decoration: none;
translate: 0 -2px;
border-color: color-mix(in srgb, var(--accent) 50%, var(--rule));
box-shadow: 0 0 20px -4px color-mix(in srgb, var(--accent) 30%, transparent);
}
@@ -249,7 +255,9 @@ const itemList = buildItemList(
width: 100%;
height: auto;
background: var(--rule);
transition: transform 0.5s cubic-bezier(0.22, 1, 0.36, 1);
}
.tile:hover .media { transform: scale(1.03); }
.text {
padding: 0.85rem 1rem 1rem;