animation strip

This commit is contained in:
Vincent van der Wal
2026-08-01 17:13:44 +02:00
parent d7fe0bafc8
commit 49e011c20b
5 changed files with 47 additions and 21 deletions
@@ -185,9 +185,9 @@
const target = Date.parse(`${param}T12:00:00Z`);
if (Number.isNaN(target)) return;
const today = new Date();
today.setUTCHours(12, 0, 0, 0);
const offset = Math.round((target - today.getTime()) / 86_400_000);
const now = new Date();
const todayNoon = Date.UTC(now.getFullYear(), now.getMonth(), now.getDate(), 12);
const offset = Math.round((target - todayNoon) / 86_400_000);
if (offset < -3 || offset > 15) return; // out of reach: stay on today
wantedDay = param;
@@ -215,7 +215,7 @@
const isToday = fetchedDaily
? dayKey === formatZoned(new Date(), fetchedDaily.timezone, 'yyyy-MM-dd')
: false;
syncSearchParams($page.url, {
syncSearchParams(get(page).url, {
day: isToday ? null : dayKey,
model: model && model !== 'best_match' ? model : null
});
@@ -62,6 +62,7 @@
// with a short CSS transition instead: an IntersectionObserver on the same
// sentinel toggles `.compact`. No per-frame scroll handler anywhere.
let sentinelEl = $state<HTMLDivElement>();
let stripEl = $state<HTMLDivElement>();
let stripScrollEl = $state<HTMLDivElement>();
let daysWrapEl = $state<HTMLDivElement>();
let needsSnapFallback = $state(false);
@@ -71,21 +72,40 @@
onMount(() => {
const scrubSupported =
CSS.supports('animation-timeline: view()') && CSS.supports('timeline-scope: none');
if (scrubSupported || !sentinelEl) return;
needsSnapFallback = true;
// `stuck` fires the moment the strip pins (turns the bar opaque before any
// content can slide under it); `compact` snaps the cells once more than
// half of the sentinel band has scrolled past. The transitions smooth both.
const io = new IntersectionObserver(
(entries) => {
const e = entries[entries.length - 1];
stuck = e.intersectionRatio < 0.97;
compact = e.intersectionRatio < 0.5;
},
{ threshold: [0.25, 0.5, 0.75, 0.97] }
);
io.observe(sentinelEl);
return () => io.disconnect();
if (scrubSupported || !sentinelEl || !stripEl) return;
// Without scroll-driven animations (Firefox, older Safari) the collapse used
// to snap between the two states at a threshold, which reads as a jump next
// to the smooth scrub everywhere else. Drive `--strip-p` from the scroll
// position instead, so the cells shrink with the scroll exactly as they do
// natively. One rAF-coalesced write per frame, no layout thrash.
const sentinel = sentinelEl;
const strip = stripEl;
const scroller = sentinel.closest('main');
let frame = 0;
const update = () => {
frame = 0;
const band = sentinel.getBoundingClientRect();
const top = scroller ? scroller.getBoundingClientRect().top : 0;
const height = band.height || 1;
const progress = Math.min(1, Math.max(0, (top - band.top) / height));
strip.style.setProperty('--strip-p', String(progress));
strip.style.setProperty('--stuck', progress > 0.02 ? '1' : '0');
};
const schedule = () => {
if (!frame) frame = requestAnimationFrame(update);
};
update();
const target: EventTarget = scroller ?? window;
target.addEventListener('scroll', schedule, { passive: true });
window.addEventListener('resize', schedule);
return () => {
if (frame) cancelAnimationFrame(frame);
target.removeEventListener('scroll', schedule);
window.removeEventListener('resize', schedule);
};
});
// Start scrolled so the "Past" button sits just off the left edge (revealed by
@@ -129,6 +149,7 @@
<div bind:this={sentinelEl} class="sentinel" aria-hidden="true"></div>
<div
bind:this={stripEl}
class="daystrip sticky -top-3 z-30 -mx-3 lg:-top-6 lg:-mx-8 mt-1"
class:js-snap={needsSnapFallback}
class:compact