Pinned Horizontal Track
A full-height section is pinned by ScrollTrigger while a flex strip inside it translates on x. The pin lasts exactly as many scroll pixels as the strip has to travel, so the mapping is 1:1 and the section releases the moment the last panel lands. The tween and the trigger are handed back to the parent, so reveal systems and drag handles can drive off the same pin instead of building a second one.
Pinned Horizontal Track
Live preview
The real component, running in this page.
Install
Copies the source into your project. Nothing is imported from us at runtime.
npx shadcn@latest add https://kinetic-seven-taupe.vercel.app/r/pinned-horizontal-track.jsonPrompt
The whole technique, written for an agent.
Build it with your agent
A single self-contained brief: the technique, the constants and why they hold, the pitfalls, and the definition of done. Paste it and the component gets written in your own conventions instead of copied in ours.
Source
2 files, exactly as installed.
API
Every parameter, its type and what happens when you change it.
| Parameter | Type | Default | Description |
|---|---|---|---|
| children | ReactNode | — | Panels. The track is a flex row, so give each panel a width and shrink-0. |
| scroller | RefObject<HTMLElement | null> | — | The scroll container the trigger reads. Omit to use the document. |
| scrub | number | = 1 | Seconds of catch-up between scrollbar and strip. |
| onTrack | (track: PinnedTrack | null) => void | — | Receives { animation, trigger, getDistance } on build and null on teardown. |
| className | string | — | Merged onto the pinned root. |
| trackClassName | string | — | Merged onto the moving strip. |
How it works
The technique in order, not the code in order.
One measurement backs the whole construction:
distanceOf() = max(0, track.scrollWidth - root.clientWidth). It is a function, not a captured number, because fonts, images and container resizes all change it after first paint.gsap.to(track, { x: () => -distanceOf(), ease: "none" })uses a function-based end value, soinvalidateOnRefresh: truere-derives the travel on every refresh rather than replaying a stale target.The ScrollTrigger pins the root with
start: "top top"andend: () => "+=" + distanceOf(). Because the pin duration and the tween distance come from the same call, one scroll pixel always buys one track pixel.scrub: 1gives the strip one second of catch-up, so a flicked wheel produces a glide rather than a jump;anticipatePin: 1lets ScrollTrigger apply the pin a frame early, which removes the single-frame jitter at high scroll speeds.document.fonts.readytriggersScrollTrigger.refresh(). A font swap changesscrollWidthafter the pin was measured, and without the refresh the pin ends before the track does — the last panel is unreachable.On build the component calls
onTrack({ animation, trigger, getDistance }); on teardown it callsonTrack(null), so consumers rebuild rather than holding a reverted tween.
Load-bearing constants
| Constant | Value | Why this number |
|---|---|---|
| scrub | = 1 | Seconds of catch-up between the scrollbar and the strip. true (0) locks the track to the scrollbar and inherits every wheel step as a visible stutter; above ~1.5 the track keeps moving long after the wheel stops and reads as broken input. |
| anticipatePin | = 1 | Applies the pin one frame of predicted scroll early. At 0, a fast flick pins one frame late and the section visibly slips before locking. Above 1 it pins early enough to look premature. |
| end | = `+=${max(1, distanceOf())}` | Pin duration in pixels, equal to the horizontal travel. The max(1, …) floor exists because a track narrower than its container yields 0, and an end equal to start makes ScrollTrigger treat the trigger as degenerate and never fire it. |
Behaviour
What it does about motion sensitivity, teardown and being finished.
No ScrollTrigger and no pin are created. The root becomes a horizontally scrollable row, so the same panels are still read left to right — under the reader's own control, at the reader's own speed.
Calls onTrack(null) so consumers drop their reference, then ctx.revert(), which kills the tween and the ScrollTrigger, removes the injected pin-spacer, and clears the inline transform from the strip. A pending document.fonts.ready refresh is gated behind a cancelled flag.
Scroll down: the section locks, the panels slide left, and the last panel reaches the right edge at exactly the moment the section unpins. Resize the container mid-pin and the track still ends flush. With reduced motion on, the section never pins and the panels can be swiped sideways instead.
Pitfalls
- Measuring
scrollWidth - clientWidthonce into a variable. Fonts swap after first paint and the number grows; the pin then ends before the track has finished travelling and the final panel can never be reached. - Expressing
endas a percentage of viewport height ("+=" + distance * 100 / innerWidth + "%"). It works on the machine it was tuned on and finishes the track early on a shorter screen, because the mapping now depends on window shape rather than on the travel itself. - Passing
scroller: nullwhen the component sits on the page rather than in a box. ScrollTrigger readsnullas an explicit scroller element and throws on the first refresh; omit the option instead, which is why the code coalesces toundefined. - Calling
kill()instead ofrevert()on unmount.kill()leaves the pin-spacer div GSAP injected into the DOM and the inline transform on the strip, so the next mount measures a layout that still contains the last one. - Forgetting that a pinned element inside a custom scroller needs that scroller passed explicitly. Without it, ScrollTrigger measures the document, the trigger never becomes active inside the box, and the panels sit motionless.
Notes
- Under
prefers-reduced-motion: reduceno trigger is created at all and the strip becomes a nativeoverflow-x-autorow, so every panel stays reachable by thumb, trackpad and keyboard. gsap.registerPlugin(ScrollTrigger)runs once at module scope behind atypeof windowguard, so repeated imports are harmless and prerender never touches the plugin.