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:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user