Repository navigation
feat: add ScrollTimeline, a scroll-driven horizontal timeline - #7
Merged
Merged
Conversation
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>
This was referenced Aug 26, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
renderItemowns the card; the axis, stem, dot, group headings and roving-tabindex keyboard nav belong to the component.API
renderItem'sdistanceargument 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(default100svh),--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, butmotion.*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.mdand the README:offsetParentup to it. Aposition: staticcontainer 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.useScrollmust not run as a layout effect. It readscontainer.currentonce, on mount, and React attaches a parent's ref after its children's layout effects run — so a layout-effect read findsnulland silently falls back to scrolling the window, which is the one thingscrollContainerRefexists to prevent. HencelayoutEffect: false.Also worth knowing: the sticky viewport deliberately does not set
overscroll-behavior: none.overflow: hiddenstill 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,
renderItempassthrough, 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.cssstill 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