Draggable Timeline Horizontal Scroll — 500vw Editorial Track Panned by a Bottom Scrubber
Goal
Build a full-screen, black editorial fashion gallery whose content lives on one enormous 500vw horizontal track (five side-by-side full-viewport sections that alternate text and image spreads). The page itself never scrolls. Instead a draggable "timeline" scrubber pinned to the bottom of the screen — sitting over a ruler of thin vertical tick-marks — is dragged left/right, and that drag pans the whole track horizontally. The star effect: as you drag the scrubber, its position is normalized to a 0→1 progress that maps onto a 0 → −400vw pan, and the track eases to that target with a trailing, momentum-style power3.out tween (each drag frame re-fires a 1s tween, so the track glides and lags smoothly behind your hand rather than snapping). Built with GSAP Draggable.
Tech
Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the single GSAP plugin Draggable. No ScrollTrigger, no smooth-scroll library, no canvas/WebGL. Import as:
import gsap from "gsap";
import { Draggable } from "gsap/Draggable"; // or "gsap/all"
gsap.registerPlugin(Draggable);
Layout / HTML
nav (fixed strip across the top)
a "Urban Eclipse" (fictional brand / logo)
a "About"
a "Contact"
a "Work"
.container (the 500vw horizontal track — flex row of 5 sections)
section#section-1 (TEXT spread: heading + paragraph)
h1 <long editorial headline>
p <two editorial paragraphs, separated by <br><br>>
section#section-2 (IMAGE spread: 3 images)
.img.img-1 > img (wide slot, flex:2)
.img.img-2 > img (narrow slot, flex:1)
.img.img-3 > img (narrow slot, flex:1)
section#section-3 (IMAGE spread: 3 images)
.img.img-4 > img (narrow, flex:1)
.img.img-5 > img (wide, flex:2)
.img.img-6 > img (narrow, flex:1)
section#section-4 (TEXT spread: heading + paragraph)
h1
p
section#section-5 (IMAGE spread: 3 images)
.img.img-7 > img (narrow, flex:1)
.img.img-8 > img (wide, flex:2)
.img.img-9 > img (narrow, flex:1)
.timeline (fixed scrubber rail across the bottom)
.scroller > p (the draggable handle) "[<span>Drag</span>]"
(+ 50 .marker tick divs injected by JS)
All copy is fictional/neutral — brand "Urban Eclipse", nav links About / Contact / Work, and long dystopian-fashion editorial paragraphs. No real brand names.
Styling
Global reset: * { margin:0; padding:0; box-sizing:border-box; }. html, body { width:100%; height:100%; background:#000; color:#fff; }. img { width:100%; height:100%; object-fit:cover; }.
Palette: pure black #000 background, white #fff text/markers. Nothing else.
Fonts (the original uses two proprietary faces — name them with sane web fallbacks):
- "Akkurat Mono" (monospace) for the nav links and the scrubber label — any clean monospace substitute is fine. Nav links
a:text-decoration:none; color:#fff; text-transform:uppercase; font-size:12px;. Scrubberspan:font-size:13px; padding:0 3em; text-transform:uppercase;. - "PP Neue Montreal" (a neutral neo-grotesque sans) for
h1andp— substitute Inter / Neue Haas / any clean grotesque.h1 { width:50%; font-weight:400; font-size:40px; text-transform:uppercase; },p { width:40%; font-weight:400; font-size:16px; }.
Load-bearing structural CSS (the JS reads pixel widths off these, so keep the geometry):
nav { position:fixed; top:0; width:100vw; padding:2em; display:flex; justify-content:space-between; }..container { position:absolute; top:0; left:0; width:500vw; height:90vh; display:flex; }— the single wide track. (This is the element GSAP translates onx.)section { position:relative; width:100vw; height:100%; padding:6em 2em 0 2em; display:flex; gap:2em; overflow:hidden; }— each section is exactly one viewport wide.- Text sections only:
#section-1, #section-4 { display:flex; justify-content:space-between; }(heading pinned left, paragraph pinned right). .img { width:100%; height:100%; }. Image sizing is done purely by flex within the section row:.img-1, .img-5, .img-8 { flex:2; }(the wide slot) and.img-2,.img-3,.img-4,.img-6,.img-7,.img-9 { flex:1; }(narrow slots). So each image section is awide | narrow | narrowornarrow | wide | narrowcomposition, all90vhtall, hard-cropped withobject-fit:cover..timeline { position:fixed; bottom:0; left:0; width:100vw; height:10vh; padding:2.25em 1em; display:flex; justify-content:space-around; }— the bottom rail;justify-content:space-aroundevenly distributes the 50 tick markers across the full width..marker { width:1px; height:100%; background:#fff; }— a thin vertical ruler tick (50 of them, injected by JS)..scroller { position:absolute; top:50%; left:0; transform:translate(0%, -50%); background:#000; cursor:pointer; text-transform:uppercase; line-height:120%; }— the draggable handle; a small black[ Drag ]pill floating over the ruler, vertically centered in the 10vh rail.font-familymono.
GSAP effect (exhaustive — this is the whole component)
Everything runs once inside window.onload (so all fonts/layout are final and offsetWidth reads are correct).
1. Measure the rail
const timeline = document.querySelector(".timeline");
const scroller = document.querySelector(".scroller");
const container = document.querySelector(".container");
const timelineWidth = timeline.offsetWidth; // ≈ viewport width (100vw)
const scrollerWidth = scroller.offsetWidth; // width of the [Drag] pill
const gap = parseInt(window.getComputedStyle(document.body).fontSize); // root font-size in px (≈16)
const maxDragX = timelineWidth - scrollerWidth - 2 * gap; // usable travel of the handle
gap (= the computed body font-size, ~16px) is reused as the left/right inset of the handle's travel.
2. Inject the 50 ruler ticks
Before creating the Draggable, append 50 .marker divs into .timeline:
for (let i = 0; i < 50; i++) {
const marker = document.createElement("div");
marker.classList.add("marker");
timeline.appendChild(marker);
}
They become flex children spread by justify-content:space-around, forming the tick ruler behind/around the scrubber.
3. The Draggable scrubber → track pan
Draggable.create(scroller, {
type: "x",
bounds: {
minX: gap,
maxX: timelineWidth - scrollerWidth - gap,
},
onDrag: function () {
let progress = (this.x - gap) / maxDragX; // normalize handle x → 0..1
let containerX = -400 * (timelineWidth / 100) * progress; // 0..1 → 0 .. -400vw
gsap.to(container, {
x: containerX,
duration: 1,
ease: "power3.out",
});
},
});
Precise behavior:
type: "x"— the handle only slides horizontally.boundsclamp the handle betweenminX = gapandmaxX = timelineWidth − scrollerWidth − gap, i.e. it can never leave the ruler (a one-gapinset on each side). The width of that travel equalsmaxDragX.onDragfires every frame the handle moves.this.xis the handle's live x-offset in px.progress = (this.x − gap) / maxDragXconverts it to a 0→1 ratio across the rail.- The pan target is
containerX = −400 · (timelineWidth/100) · progress. SincetimelineWidth/100is onevwin px, this is exactly −400vw · progress → atprogress = 0the track sits atx = 0(section 1 visible), atprogress = 1the track is at −400vw, revealing the 5th (last) 100vw section. The 500vw track minus the 100vw viewport = 400vw of travel — the mapping consumes it exactly. - The momentum/trailing feel: each
onDragre-issuesgsap.to(container, { x: containerX, duration: 1, ease: "power3.out" }). GSAP overwrites the in-flight tween on every frame, so the track is continuously chasing a moving 1-secondpower3.outtarget. The result is a soft, inertial lag — the track glides toward and eases to a stop behind the handle instead of tracking it rigidly, and keeps easing for up to ~1s after you release.
No ScrollTrigger, no scrub, no pin, no timeline sequencing, no other tweens.
Assets / images
9 images, a cohesive moody editorial fashion series on a black page, each filling its flex slot at 90vh tall with object-fit:cover (so source aspect is forgiving; the wide flex:2 slots read landscape-ish, the narrow flex:1 slots read portrait-ish). Mix warm golden-hour outdoor fashion, high-contrast black-and-white studio portraits, motion shots, and clean product still-lifes of leather accessories — all dark, elegant, dystopian-editorial. By role:
- img-1 (section 2, wide
flex:2): warm golden-hour outdoor fashion shot, figures seen from behind in earth-tone outerwear in a dry grassy field. - img-2 (section 2, narrow
flex:1): clean product still-life — a hand holding a tan leather zip pouch against a plain off-white backdrop. - img-3 (section 2, narrow
flex:1): moody black-and-white studio torso in an oversized tailored blazer, diagonal shafts of light. - img-4 (section 3, narrow
flex:1): product still-life — a hand carrying a tan leather flat clutch/sleeve against a light crumpled-paper backdrop. - img-5 (section 3, wide
flex:2): editorial portrait lit by a hard shaft of light inside a derelict concrete interior, layered casualwear. - img-6 (section 3, narrow
flex:1): black-and-white low-angle portrait, sunglasses, wind blowing hair against a bright sky. - img-7 (section 5, narrow
flex:1): product still-life — a figure carrying a tan leather shoulder/hobo bag against a warm beige backdrop. - img-8 (section 5, wide
flex:2): golden-hour outdoor portrait from behind, earth-tone wool coat, weathered fence in a dry field. - img-9 (section 5, narrow
flex:1): black-and-white motion shot — a figure twirling in a flowing dark dress and platform boots.
Behavior notes
- The page never scrolls —
.containerisposition:absoluteand moved only by GSAP; the sole interaction is dragging the bottom scrubber. Nav and timeline areposition:fixedover the stage. - No autoplay / no loop — the track is at rest (
x:0, section 1) on load and only moves while (and shortly after) the handle is dragged. - The mapping is anchored to
timelineWidth(read on load), so it is correct at the initial viewport size; there is noresizere-measure — a mid-session resize would leave the pan ratio slightly off (matches the original, which sets everything once inwindow.onload). - Desktop-oriented drag interaction; Draggable also handles touch, so a finger-drag on the pill works on touch devices.
- Reduced-motion: nothing animates on its own, so it's already calm; the only motion is user-initiated dragging.