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);
|
border: 2px solid var(--rule);
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: inherit;
|
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 {
|
.tile:hover {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
translate: 0 -2px;
|
||||||
border-color: color-mix(in srgb, var(--accent) 50%, var(--rule));
|
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);
|
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%;
|
width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
background: var(--rule);
|
background: var(--rule);
|
||||||
|
transition: transform 0.5s cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
}
|
}
|
||||||
|
.tile:hover .media { transform: scale(1.03); }
|
||||||
|
|
||||||
.text {
|
.text {
|
||||||
padding: 0.85rem 1rem 1rem;
|
padding: 0.85rem 1rem 1rem;
|
||||||
|
|||||||
@@ -186,10 +186,18 @@ function host(url: string) {
|
|||||||
border: 1px solid var(--rule);
|
border: 1px solid var(--rule);
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
overflow: hidden;
|
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 {
|
.find-card:hover {
|
||||||
transform: translateY(-2px);
|
translate: 0 -2px;
|
||||||
border-color: color-mix(in srgb, var(--accent) 40%, var(--rule));
|
border-color: color-mix(in srgb, var(--accent) 40%, var(--rule));
|
||||||
box-shadow: 0 8px 24px -10px rgba(0, 0, 0, 0.18);
|
box-shadow: 0 8px 24px -10px rgba(0, 0, 0, 0.18);
|
||||||
}
|
}
|
||||||
@@ -201,7 +209,9 @@ function host(url: string) {
|
|||||||
height: auto;
|
height: auto;
|
||||||
background: var(--card-fill);
|
background: var(--card-fill);
|
||||||
border-bottom: 1px solid var(--rule);
|
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 {
|
.content {
|
||||||
padding: 0.95rem 1.05rem 1rem;
|
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 sources = document.querySelectorAll<HTMLDivElement>('.masonry-source');
|
||||||
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
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.
|
// 1rem in px (html font-size = 18px here) — the grid gap, kept robust to changes.
|
||||||
function rootRem(): number {
|
function rootRem(): number {
|
||||||
return parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
|
return parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
|
||||||
@@ -154,6 +158,7 @@ const dataBatch = batch != null ? String(batch) : undefined;
|
|||||||
if (!animate) {
|
if (!animate) {
|
||||||
for (const t of placed) {
|
for (const t of placed) {
|
||||||
const p = newPos.get(t)!;
|
const p = newPos.get(t)!;
|
||||||
|
t.style.transitionDelay = '';
|
||||||
t.style.transform = `translate(${p.x}px, ${p.y}px)`;
|
t.style.transform = `translate(${p.x}px, ${p.y}px)`;
|
||||||
t.style.opacity = '1';
|
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
|
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');
|
masonry.classList.remove('is-measuring');
|
||||||
|
let revealIdx = 0;
|
||||||
for (const t of placed) {
|
for (const t of placed) {
|
||||||
const p = newPos.get(t)!;
|
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.width = cardW + 'px';
|
||||||
t.style.transform = `translate(${p.x}px, ${p.y}px)`;
|
t.style.transform = `translate(${p.x}px, ${p.y}px)`;
|
||||||
t.style.opacity = '1';
|
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.
|
// 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>) {
|
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);
|
window.clearTimeout(settleTimer);
|
||||||
settleTimer = window.setTimeout(() => layout({}), 600);
|
settleTimer = window.setTimeout(() => layout({}), 600 + stagger);
|
||||||
layout({ flip: true, reveal });
|
layout({ flip: true, reveal });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,7 +250,8 @@ const dataBatch = batch != null ? String(batch) : undefined;
|
|||||||
else layout({});
|
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) {
|
if (progressive && stash.length > 0) {
|
||||||
loadMore.addEventListener('click', () => {
|
loadMore.addEventListener('click', () => {
|
||||||
@@ -292,9 +309,15 @@ const dataBatch = batch != null ? String(batch) : undefined;
|
|||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
margin: 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),
|
transition: transform 0.5s cubic-bezier(0.22, 1, 0.36, 1),
|
||||||
width 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;
|
will-change: transform, width;
|
||||||
}
|
}
|
||||||
/* During measurement/inversion we position with transitions off. */
|
/* 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: 1px solid var(--rule);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
cursor: pointer;
|
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 {
|
.masonry-load-more:hover {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
border-color: color-mix(in srgb, var(--accent) 50%, var(--rule));
|
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>
|
</style>
|
||||||
|
|||||||
@@ -212,9 +212,35 @@ const year = new Date().getFullYear();
|
|||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
body { transition: none; }
|
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; }
|
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 */
|
||||||
.masthead {
|
.masthead {
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
@@ -420,7 +446,16 @@ const year = new Date().getFullYear();
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 0.95rem;
|
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 {
|
.external .aff-badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-left: 0.6rem;
|
margin-left: 0.6rem;
|
||||||
@@ -519,6 +554,27 @@ const year = new Date().getFullYear();
|
|||||||
.colophon dt:first-child { margin-top: 0; }
|
.colophon dt:first-child { margin-top: 0; }
|
||||||
}
|
}
|
||||||
</style>
|
</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 />}
|
{import.meta.env.DEV && <EditConfirm />}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+10
-2
@@ -184,10 +184,12 @@ const itemList = buildItemList(
|
|||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: inherit;
|
color: inherit;
|
||||||
margin-bottom: 2.5rem;
|
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 {
|
.hero:hover {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
translate: 0 -2px;
|
||||||
border-color: color-mix(in srgb, var(--accent) 50%, var(--rule));
|
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);
|
box-shadow: 0 0 32px -4px color-mix(in srgb, var(--accent) 35%, transparent);
|
||||||
}
|
}
|
||||||
@@ -200,7 +202,9 @@ const itemList = buildItemList(
|
|||||||
max-height: 62vh;
|
max-height: 62vh;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
background: var(--rule);
|
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 {
|
.hero-text {
|
||||||
padding: 1.6rem 1.75rem 1.75rem;
|
padding: 1.6rem 1.75rem 1.75rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -235,10 +239,12 @@ const itemList = buildItemList(
|
|||||||
border: 2px solid var(--rule);
|
border: 2px solid var(--rule);
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: inherit;
|
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 {
|
.tile:hover {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
translate: 0 -2px;
|
||||||
border-color: color-mix(in srgb, var(--accent) 50%, var(--rule));
|
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);
|
box-shadow: 0 0 20px -4px color-mix(in srgb, var(--accent) 30%, transparent);
|
||||||
}
|
}
|
||||||
@@ -249,7 +255,9 @@ const itemList = buildItemList(
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
background: var(--rule);
|
background: var(--rule);
|
||||||
|
transition: transform 0.5s cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
}
|
}
|
||||||
|
.tile:hover .media { transform: scale(1.03); }
|
||||||
|
|
||||||
.text {
|
.text {
|
||||||
padding: 0.85rem 1rem 1rem;
|
padding: 0.85rem 1rem 1rem;
|
||||||
|
|||||||
Reference in New Issue
Block a user