feat(route): unified scroll — drag with momentum + wheel + animated glide

Replaces the snap-based scroll-by-arrow model with one that handles
three input modalities feeding the same scroll state. Result: the
track glides continuously instead of clicking into milestones.

Stripped:
  - scroll-snap-type: x mandatory on .rr-scroll
  - scroll-behavior: smooth on .rr-scroll
  - scroll-snap-align: center on .rr-dot
  - The fades-only updateNav stub from commit 2

Added on .rr-scroll:
  - cursor: grab → grabbing on .rr-dragging
  - touch-action: pan-y so vertical page-scroll passes through on mobile
    while horizontal drags activate the route's own drag handler
  - user-select: none stops text selection mid-drag
  - .rr-dragging .rr-card { pointer-events: none } so a hover-reveal
    can't pop open while the track is being dragged

Script (vanilla, ~140 lines):
  - animateScrollTo(target, durationMs): cubic-ease-out via RAF.
    Cancels any existing momentum or animation before starting.
  - Wheel handler: vertical deltaY translates to horizontal scrollLeft
    when |deltaX| < |deltaY|; horizontal trackpad gestures pass through
    1:1 unscaled. preventDefault on this scroll element only — vertical
    wheel elsewhere on the page scrolls the page as normal.
  - Pointer-drag: pointerdown captures the start position + scrollLeft;
    pointermove updates scrollLeft and tracks velocity in px/ms over
    the most recent sample. setPointerCapture for cross-element drag.
  - Momentum on release: signed velocity × 16ms decays at 0.93 per
    frame, stops below 0.4 px/frame. Direction inverted because
    dragging right moves scrollLeft left.
  - Click vs drag discrimination at 5px total movement: under 5px,
    the synthetic click passes through (card navigates); over 5px,
    a capturing-phase click suppressor on the scroll element eats
    the next click so a drag-then-release-over-a-card doesn't
    accidentally navigate.
  - Advance arrow click now runs animateScrollTo(scrollLeft + 60% of
    viewport, 480ms) instead of the placeholder native scrollBy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan Hvid 2026-05-12 15:19:13 +02:00
parent acbb722a0a
commit f90480bc8b

View file

@ -210,9 +210,7 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
milestones.forEach((m, i) => { m.style.left = `${itemX[i]}px`; }); milestones.forEach((m, i) => { m.style.left = `${itemX[i]}px`; });
} }
/* Edge state — fades + advance disable. The smooth glide animation /* Edge state — fades + advance disable. */
on advance click lands in the next commit; for now the click
uses native smooth scrollBy. */
function updateNav() { function updateNav() {
const max = scroll!.scrollWidth - scroll!.clientWidth; const max = scroll!.scrollWidth - scroll!.clientWidth;
const atStart = scroll!.scrollLeft <= 2; const atStart = scroll!.scrollLeft <= 2;
@ -222,8 +220,128 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
if (advance) advance.classList.toggle('rr-at-end', atEnd); if (advance) advance.classList.toggle('rr-at-end', atEnd);
} }
/* ── Unified scroll handling: wheel, drag, animated glide. ──
No CSS scroll-snap and no scroll-behavior: smooth — both fight
the JS-driven smooth motion. Drag has momentum; wheel translates
vertical to horizontal; arrow click runs a cubic-ease animation. */
let isDragging = false;
let dragStartX = 0;
let dragStartScrollLeft = 0;
let dragTotalMovement = 0;
let lastMoveX = 0;
let lastMoveTime = 0;
let velocity = 0; // px/ms, signed (positive = pointer moving right)
let momentumRAF: number | null = null;
let animateRAF: number | null = null;
function cancelAnims() {
if (momentumRAF !== null) { cancelAnimationFrame(momentumRAF); momentumRAF = null; }
if (animateRAF !== null) { cancelAnimationFrame(animateRAF); animateRAF = null; }
}
function animateScrollTo(target: number, durationMs: number) {
cancelAnims();
const start = scroll!.scrollLeft;
const delta = target - start;
const startTime = performance.now();
const easeOut = (t: number) => 1 - Math.pow(1 - t, 3);
const step = () => {
const t = Math.min(1, (performance.now() - startTime) / durationMs);
scroll!.scrollLeft = start + delta * easeOut(t);
updateNav();
if (t < 1) animateRAF = requestAnimationFrame(step);
else animateRAF = null;
};
animateRAF = requestAnimationFrame(step);
}
// Wheel — vertical wheel becomes horizontal scroll on this element.
// Trackpads sending horizontal deltaX go through unchanged (1:1, no scaling).
scroll.addEventListener('wheel', (e) => {
const dx = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
if (dx === 0) return;
e.preventDefault();
cancelAnims();
scroll!.scrollLeft += dx;
updateNav();
}, { passive: false });
// Drag — pointer events; momentum on release.
scroll.addEventListener('pointerdown', (e) => {
if (e.button !== undefined && e.button !== 0) return;
// Don't start a drag when the click target is the advance button.
if (advance && advance.contains(e.target as Node)) return;
isDragging = true;
dragStartX = e.pageX;
dragStartScrollLeft = scroll!.scrollLeft;
dragTotalMovement = 0;
lastMoveX = e.pageX;
lastMoveTime = performance.now();
velocity = 0;
cancelAnims();
try { scroll!.setPointerCapture(e.pointerId); } catch { /* not all envs */ }
scroll!.classList.add('rr-dragging');
});
scroll.addEventListener('pointermove', (e) => {
if (!isDragging) return;
const dx = e.pageX - dragStartX;
scroll!.scrollLeft = dragStartScrollLeft - dx;
dragTotalMovement = Math.max(dragTotalMovement, Math.abs(dx));
const now = performance.now();
const dt = now - lastMoveTime;
if (dt > 0) velocity = (e.pageX - lastMoveX) / dt;
lastMoveX = e.pageX;
lastMoveTime = now;
updateNav();
});
function endDrag() {
if (!isDragging) return;
isDragging = false;
scroll!.classList.remove('rr-dragging');
// Click vs drag: anything under 5px total movement is a click —
// skip momentum and let the underlying card's <a> handle the click.
if (dragTotalMovement < 5) return;
// Otherwise it's a real drag — suppress the synthetic click that
// follows so a drag-then-release-over-a-card doesn't navigate.
const suppressClick = (ev: Event) => {
ev.stopPropagation();
ev.preventDefault();
scroll!.removeEventListener('click', suppressClick, true);
};
scroll!.addEventListener('click', suppressClick, true);
// Momentum: signed velocity, decay 0.93 per frame, stop under 0.4 px/frame.
// Direction inverted because dragging right moves scrollLeft left.
let v = -velocity * 16;
const step = () => {
if (Math.abs(v) < 0.4) { momentumRAF = null; return; }
scroll!.scrollLeft += v;
v *= 0.93;
updateNav();
momentumRAF = requestAnimationFrame(step);
};
momentumRAF = requestAnimationFrame(step);
}
scroll.addEventListener('pointerup', endDrag);
scroll.addEventListener('pointercancel', endDrag);
// Advance arrow — animated glide of 60% viewport width.
advance?.addEventListener('click', () => { advance?.addEventListener('click', () => {
scroll!.scrollBy({ left: scroll!.clientWidth * 0.6, behavior: 'smooth' }); const target = Math.min(
scroll!.scrollLeft + scroll!.clientWidth * 0.6,
scroll!.scrollWidth - scroll!.clientWidth,
);
animateScrollTo(target, 480);
}); });
scroll.addEventListener('scroll', updateNav, { passive: true }); scroll.addEventListener('scroll', updateNav, { passive: true });
@ -268,25 +386,31 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
margin-right: calc(50% - 50vw); margin-right: calc(50% - 50vw);
} }
.rr-scroll { .rr-scroll {
/* overflow-x: auto + overflow-y: visible is the only thing that lets /* overflow-x: auto + overflow-y: visible lets hovered cards expand
hovered cards expand above/below the track without being clipped. above/below the track without being clipped. .rr-scroll-inner is
The previous fix bandaged it with extra trackHeight; this is the the spec-recommended belt-and-braces wrapper in case a browser
real fix. The .rr-scroll-inner wrapper is the spec-recommended misbehaves on the combination.
belt-and-braces in case a browser misbehaves on this combination. */ NO scroll-snap-type and NO scroll-behavior: smooth — both fight
the JS drag-momentum + animated-glide implementation below. The
path is meant to glide continuously, not click into fixed
positions. */
overflow-x: auto; overflow-x: auto;
overflow-y: visible; overflow-y: visible;
scroll-snap-type: x mandatory;
scroll-behavior: smooth;
scrollbar-width: none; scrollbar-width: none;
/* Top/bottom give cards room to grow above/below the track. The 80px
sides give the first/last cards room when fully scrolled inside
the now full-bleed container — small but visible breathing room
between the route and the absolute viewport edges. */
padding: 60px 80px 80px; padding: 60px 80px 80px;
scroll-padding-left: 80px;
scroll-padding-right: 80px; /* Drag affordance: cursor + suppress native horizontal swipe so
horizontal drag triggers our handler while vertical drag still
scrolls the page. user-select stops drag from selecting text. */
cursor: grab;
touch-action: pan-y;
user-select: none;
} }
.rr-scroll::-webkit-scrollbar { display: none; } .rr-scroll::-webkit-scrollbar { display: none; }
.rr-scroll.rr-dragging { cursor: grabbing; }
/* Pointer-events off the cards mid-drag — prevents accidental hover
reveal while the track is being dragged past. */
.rr-scroll.rr-dragging .rr-card { pointer-events: none; }
.rr-scroll-inner { /* structural — keeps the track on its own layer */ } .rr-scroll-inner { /* structural — keeps the track on its own layer */ }
.rr-track { position: relative; } .rr-track { position: relative; }
.rr-path { position: absolute; top: 0; left: 0; pointer-events: none; } .rr-path { position: absolute; top: 0; left: 0; pointer-events: none; }
@ -302,7 +426,6 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
border-radius: 50%; border-radius: 50%;
box-shadow: 0 0 0 5px var(--background); /* halo cuts the path under the dot */ box-shadow: 0 0 0 5px var(--background); /* halo cuts the path under the dot */
transition: transform .25s ease, box-shadow .25s ease; transition: transform .25s ease, box-shadow .25s ease;
scroll-snap-align: center;
} }
.rr-dot.rr-current { .rr-dot.rr-current {
transform: scale(1.3); transform: scale(1.3);