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:
parent
acbb722a0a
commit
f90480bc8b
1 changed files with 141 additions and 18 deletions
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue