Skip to content

feat: add ScrollTimeline, a scroll-driven horizontal timeline - #7

Merged
parammehta merged 1 commit into
mainfrom
feat/scroll-timeline
Aug 26, 2026
Merged

parammehta merged 1 commit into
mainfrom
feat/scroll-timeline

Conversation

@parammehta

Copy link
Copy Markdown
Owner

A horizontal timeline whose travel is driven by vertical scroll: the section reserves a runway derived from the item count, a viewport inside it sticks to the top, and progress through the runway pans the track sideways.

Built for the portfolio's home-page experience section, which is replacing its 3D carousel with this — but the component itself carries no portfolio content. renderItem owns the card; the axis, stem, dot, group headings and roving-tabindex keyboard nav belong to the component.

API

<ScrollTimeline
  items={items}                       // { id, label, group?, accent? }[]
  label="Work history"
  renderItem={(item, { active, distance, side }) => <Card … />}
  scrollContainerRef={containerRef}   // only if the page scrolls an element
  scrollRatio={0.8}                   // runway = scrollRatio × item count, in viewports
  onActiveChange={(item, index) => …}
>
  {/* pinned inside the sticky viewport for the whole pan */}
</ScrollTimeline>

renderItem's distance argument is the signed offset from the active item, so a consumer can key expensive card content off it — the portfolio uses it to keep only 3 WebGL contexts alive across 6 nodes instead of 6.

CSS knobs: --timelineViewportHeight (default 100svh), --timelineInset (room for pinned overlay chrome), --timelineFade (edge fade).

Notable decisions

The track is written imperatively from a MotionValue subscription, not rendered as a motion.div. Both produce the same transform, but motion.* would pull framer-motion's full feature bundle into every consumer of the main entry, and nothing else in this library does that today.

Travel is measured from the slots themselves, not derived from the track's overflow. Overflow alone assumes the first item starts centred and the last ends centred, which holds only if the lead-in padding happens to equal half the viewport minus half a card. It never does, and every item lands off-centre by up to half a card.

Two silent failure modes, now documented

Both cost real debugging time and are written up in CLAUDE.md and the README:

  • The scroll container must be positioned. framer-motion locates a target inside a custom container by walking offsetParent up to it. A position: static container is never in that chain, so the walk runs off the top of the document and progress pins at 0 — the timeline renders perfectly and never moves.
  • useScroll must not run as a layout effect. It reads container.current once, on mount, and React attaches a parent's ref after its children's layout effects run — so a layout-effect read finds null and silently falls back to scrolling the window, which is the one thing scrollContainerRef exists to prevent. Hence layoutEffect: false.

Also worth knowing: the sticky viewport deliberately does not set overscroll-behavior: none. overflow: hidden still makes it a scroll box, so suppressing overscroll stops the wheel chaining out to the page — and because it covers the screen for the whole pan, the runway driving it becomes unreachable by wheel. I shipped that by accident first and the timeline was simply stuck.

Testing

10 new unit tests (region labelling, one button per item with its label, renderItem passthrough, group-heading collapsing, side alternation, roving tabindex, active reporting on mount and on focus, runway scaling, empty state). Full suite: 180 passing across 25 files. Lint, stylelint, typecheck and build all clean; dist/styles.css still resolves every @custom-media (0 occurrences of --media).

Verified end-to-end by driving the real portfolio page in a browser — full traversal in both directions with real wheel input, on desktop and mobile, every node centring at rest.

🤖 Generated with Claude Code

A horizontal timeline whose travel is driven by vertical scroll: the section
reserves a runway derived from the item count, a viewport inside it sticks to
the top, and progress through the runway pans the track sideways.

renderItem owns the card; the axis, stem, dot, group heading and roving-tabindex
keyboard nav belong to the component. Keyboard nav inverts the mapping and moves
the vertical scroll, so focus and the visible track cannot disagree.

The track is written imperatively from a MotionValue subscription rather than
rendered as a motion.div — both produce the same transform, but motion.* would
pull framer-motion's full feature bundle into every consumer of the main entry.

Travel is measured from the slots themselves rather than derived from the
track's overflow: overflow alone assumes the first item starts centred, which
holds only if the lead-in padding happens to equal half the viewport minus half
a card, and every item ends up off-centre by up to half a card.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@parammehta
parammehta merged commit 2798997 into main Aug 26, 2026
4 checks passed
@parammehta
parammehta deleted the feat/scroll-timeline branch August 26, 2026 08:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant