Fullscreen Horizontal Slider — Segmented Progress Nav + Kinetic Display-Type Titles
Goal
Build a full-screen horizontal image carousel with a signature choreography driven entirely by clicking segments in a top progress-bar navigation. Each slide holds one centered image. Above them sits a slim horizontal bar split into 30 thin segments; the active segment stretches wide (a pure-CSS flex grow) while the others stay hairline-thin. Clicking a different segment does three things at once, all 1.5s long on a custom "hop" CustomEase: (1) the whole horizontal track of slides slides sideways to the chosen slide, (2) a full-screen background-color overlay tweens to a new random color, and (3) a giant two-row display-type title re-animates — every letter is destroyed and re-created, then slides in from the side (from the right when advancing, from the left when going back) with power2.out. It's an editorial portfolio slider: oversized serif kinetic typography floating over a color wash, with a centered image per slide.
Tech
- Vanilla HTML / CSS / JS with ES module imports, bundled by a Vite-style dev server (npm project).
gsap(npm) plus exactly one GSAP plugin:CustomEase. No ScrollTrigger, no SplitText, no smooth-scroll library, no canvas/WebGL. The whole thing is click-driven — there is no scroll and no rAF loop.- Imports and registration:
import gsap from "gsap";
import { CustomEase } from "gsap/CustomEase";
gsap.registerPlugin(CustomEase);
CustomEase.create("hop", "M0,0 C0.071,0.505 0.192,0.726 0.318,0.852 0.45,0.984 0.504,1 1,1");
- Title copy lives in a separate
data.js(export const titles = [...]). All DOM building + wiring happens inside a singledocument.addEventListener("DOMContentLoaded", …)inscript.js(<script type="module" src="./script.js">).
Layout / HTML
The markup is mostly empty shells that JS fills. Class names are load-bearing. Use neutral/fictional copy — no real brand names. The demo wordmark is "Motionprompts".
<div class="container">
<nav>
<a href="#" id="logo">Motionprompts</a>
<a href="#">Subscribe</a>
</nav>
<footer>
<a href="#">Unlock Source Code with PRO</a>
<a href="#">Link in description</a>
</footer>
<div class="bg-overlay"></div> <!-- full-screen color wash -->
<div class="slider-nav"></div> <!-- JS injects 30 .nav-item-wrapper segments -->
<div class="slides"></div> <!-- JS injects 30 .slide elements in a horizontal flex row -->
<div class="slide-title">
<div class="slide-title-row"><!-- 7 empty .letter divs --></div>
<div class="slide-title-row"><!-- 7 empty .letter divs --></div>
</div>
</div>
<script type="module" src="./script.js"></script>
The two .slide-title-row divs each contain exactly 7 empty <div class="letter"> in the HTML (14 letter slots total). JS injects/replaces a <span> inside each on every title change.
Per-slide DOM (built in the JS loop, i = 0..29):
.slide (flex 1, one viewport wide)
.img (centered box, 50% × 50% of viewport)
img src = /c/aristidebenoist-slider/img{i+1}.jpg
Per-nav-segment DOM (built in the same loop):
.nav-item-wrapper (segment 0 also gets class "active")
.nav-item (the thin tick inside)
Styling
Global reset: * { margin:0; padding:0; box-sizing:border-box; }. html, body { width:100%; height:100%; }.
Fonts (name these families; supply free substitutes if unavailable):
- Body / UI:
"PP Neue Montreal"— a neutral grotesque sans (substitute: Inter / Neue Haas Grotesk). - Display (logo + the giant title letters):
"Timmons NY 2.005"— a tall, high-contrast condensed serif display face (substitute: a heavy fashion-editorial serif like a condensed Didone / GT Sectra display / Ogg). Set onnav a#logoand.letter span.
Colors / tokens:
- Text is black
#000. .bg-overlayinitial background:rgb(213, 183, 71)(a mustard/gold), withfilter: brightness(0.75);andopacity: 0.5;— a soft muted color wash over the whole viewport. Later JS tweens itsbackground-colorto random hex values.
Structure:
.container:width:100vw; height:100vh; overflow:hidden;— clips the oversized horizontal track and the giant letters.nav,footer:position:fixed; left:0; width:100vw; padding:2.75em; display:flex; justify-content:space-between; z-index:2;—nav{top:0},footer{bottom:0}.a:text-decoration:none; text-transform:uppercase; font-size:12px; font-weight:500; color:#000;.nav a#logo:position:relative; top:-12px;display serif,font-size:42px;..bg-overlay:position:fixed; inset:0; width:100vw; height:100vh;(see color tokens above).
The segmented progress nav (the click target):
.slider-nav:position:fixed; top:5%; left:50%; transform:translateX(-50%); width:25%; height:15px; display:flex; justify-content:space-between; z-index:10;..nav-item-wrapper:flex:1; height:100%; display:flex; justify-content:center; align-items:center; transition: all 750ms cubic-bezier(0, 0.75, 0.5, 1);— CSS transition, not GSAP..nav-item:width:1px; height:100%; border:1px solid rgba(0,0,0,0.15); transition: all 750ms cubic-bezier(0, 0.75, 0.5, 1);— a faint hairline tick..nav-item-wrapper.active:flex:5;— the active segment claims 5× the width of the others, so the bar reads like a progress indicator with one long lit cell..nav-item-wrapper.active .nav-item:width:50%; border:1px solid rgba(0,0,0,1);— the active tick grows and turns solid black.
The horizontal slide track:
.slides:position:fixed; top:0; left:0; width:3000vw; height:100vh; display:flex;— 3000vw = 30 slides × 100vw laid side by side. GSAP translates this whole element onx..slide:flex:1; width:100vw; height:100vh; display:flex; justify-content:center; align-items:center;..slide .img:width:50%; height:50%; opacity:0.75;— image sits in a centered box occupying half the viewport each way.img:width:100%; height:100%; object-fit:cover;.
The giant kinetic title:
.slide-title:position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); width:75%; height:65%; display:flex; flex-direction:column; pointer-events:none;— floats centered over the slides, non-interactive so nav clicks pass through..slide-title-row:flex:1; width:100%; display:flex; gap:0;(two rows)..slide-title-row:nth-child(2):position:relative; left:4em;— the second row is nudged right for an offset editorial layout..letter:flex:1; height:100%; padding-left:2em; clip-path: polygon(0 0, 100% 0, 100% 100%, 0% 100%);— each of the 7 cells per row is an equal-width column; the full-rectangleclip-pathmasks the span so a letter that slides in from the side is clipped at the cell edges (it wipes in rather than flying across)..letter span:position:relative; display:inline-block; font-family:"Timmons NY 2.005"; font-size:280px; filter: brightness(0.25) saturate(0.75) !important;— NOTE: this!importantfilter overrides the inlinefilterGSAP sets, so every letter renders as a dark, desaturated version of the current color (near-black), regardless of the random color assigned to it.
GSAP effect (be exact)
The "hop" CustomEase
CustomEase.create("hop", "M0,0 C0.071,0.505 0.192,0.726 0.318,0.852 0.45,0.984 0.504,1 1,1") — a fast-out, long-settle ease: it accelerates hard in the first ~30% then glides slowly into the end. Used for both the slide-track slide and the color tween.
Build loop (30 items, index i = 0..29)
For each i:
- Create
.nav-item-wrapper(+.nav-itemchild); segment0gets classactive. Append to.slider-nav. - Create
.slide(+.img+<img src="/c/aristidebenoist-slider/img{i+1}.jpg">); slide0also gets classactive. Append to.slides. - Attach a click handler to the wrapper (closure captures its own
ivialet).
Track two module-level values: numberOfItems = 30 and currentIndex = 0.
Click handler on a nav segment i
if (i === currentIndex) return; // ignore clicks on the current segment
// active class swap (drives the CSS width transition on the bar)
document.querySelectorAll(".nav-item-wrapper").forEach(n => n.classList.remove("active"));
thisWrapper.classList.add("active");
// 1) slide the whole track horizontally
gsap.to(".slides", { x: `${-i * 100}vw`, duration: 1.5, ease: "hop" });
// 2) tween the background wash to a fresh random hex color
const newColor = getRandomColor(); // "#" + 6 random hex chars
gsap.to(".bg-overlay", { backgroundColor: newColor, duration: 1.5, ease: "hop" });
// 3) re-animate the title (see below), then commit the index
updateTitle(i, newColor);
currentIndex = i;
getRandomColor(): builds"#"+ 6 characters picked at random from0123456789ABCDEF.- The horizontal target is **
x: "-i*100vw"** (e.g. clicking segment 7 →x: "-700vw"), so the chosen slide lands centered in the viewport.
updateTitle(newIndex, color) — the kinetic letters
titles[newIndex] is [rowArray0, rowArray1], each row a 7-element array of single characters (many are empty strings "", which create gaps → the scattered word layout). For each of the 2 .slide-title-rows, for each of its 7 .letter cells:
const direction = newIndex > currentIndex ? 150 : -150; // forward → enter from right; back → from left
// remove the old <span> if present
const span = document.createElement("span");
gsap.set(span, { x: direction, color: color, filter: "brightness(0.75)" });
span.textContent = title[rowIndex][letterIndex] || ""; // may be an empty string
letter.appendChild(span);
gsap.to(span, { x: 0, duration: 1, ease: "power2.out", delay: 0.125 });
Key points:
- Direction is decided before
currentIndexis updated, so it reflects travel direction: advancing to a higher index → letters start atx: 150(enter from the right); going back →x: -150(enter from the left). - Every letter uses the same
duration: 1,ease: "power2.out",delay: 0.125— no stagger; all 14 letters glide in together, clipped by their.lettercell so they appear to wipe in from the edge. - The
colorset inline is the same random color used for the wash, but the CSSfilter … !importanton.letter spanoverrides the inlinefilter, so letters render near-black/desaturated regardless.
Initial state (on load, after the build loop)
Call updateTitle(0, getComputedStyle(bgOverlay).backgroundColor) once. Since newIndex (0) is not > currentIndex (0), direction = -150, so the very first title wipes in from the left. Slide 0 and nav segment 0 start active; the track sits at x: 0.
Title data (data.js)
export const titles is an array of 30 entries, each [ [7 chars], [7 chars] ]. Empty strings are intentional gaps. Use short, neutral invented words (no real brands). Reproduce this exact set for a faithful result:
export const titles = [
[["p","","r","","i","s","m"], ["","t","","o","n","e",""]],
[["","l","u","m","e","","n"], ["d","","r","e","a","m",""]],
[["r","","u","s","","h",""], ["","s","l","i","","c","e"]],
[["e","","c","h","o","","e"], ["","c","o","","d","e","6"]],
[["t","e","","c","h","y",""], ["","m","","","a","p","l"]],
[["","w","a","v","","e","s"], ["b","o","","x","","",""]],
[["c","","u","b","","","e"], ["","s","i","t","","9","0"]],
[["r","u","","s","h","","x"], ["t","","o","r","","k",""]],
[["c","","o","d","","e",""], ["l","a","b","","","0","8"]],
[["m","i","x","","e","","d"], ["","","a","r","","k",""]],
[["","t","e","","s","t",""], ["b","","e","d","","5","4"]],
[["f","o","c","u","","","s"], ["","d","o","c","k","",""]],
[["p","","a","","c","e",""], ["s","e","t","","1","","7"]],
[["","b","","l","a","s","t"], ["m","o","","d","","","e"]],
[["z","o","","n","e","",""], ["g","e","3","","","n",""]],
[["d","","r","e","a","","m"], ["s","c","a","p","0","",""]],
[["e","l","e","v","a","n",""], ["","p","a","","t","","h"]],
[["","s","","h","i","f","t"], ["","n","e","","","u","e"]],
[["i","","c","o","","","n"], ["","m","e","m","o","",""]],
[["","a","","","u","r","a"], ["w","","a","v","e","","6"]],
[["s","t","e","l","","l","a"], ["","o","","r","b","i","t"]],
[["v","","e","r","t","e",""], ["c","o","","r","","e",""]],
[["i","n","f","i","","9",""], ["","","","e","t","h","o"]],
[["","","q","u","a","n","t"], ["d","e","","c","","","k"]],
[["","n","","","o","v","a"], ["r","","a","y","","",""]],
[["","r","a","d","i","a","n"], ["g","l","o","","","w","0"]],
[["c","o","s","m","i","c",""], ["p","","a","t","h","",""]],
[["","s","o","l","a","r",""], ["d","r","i","f","","","t"]],
[["z","e","n","","l","a","y"], ["","e","r","v","","y",""]],
[["a","p","e","","x","",""], ["f","o","r","g","e","0","0"]],
[["","c","r","y","s","t","a"], ["l","b","y","t","e","",""]],
];
Assets / images
30 slide images at /c/aristidebenoist-slider/img1.jpg … img30.jpg. Each is shown in a centered box occupying 50% × 50% of the viewport with object-fit: cover at opacity: 0.75, so any aspect ratio works (roughly square-to-landscape framing reads best; crops are inevitable). Content is an eclectic editorial mix — extreme close-up portraits, moody studio product shots, abstract 3D renders (glossy discs, spheres, stacked panels), gadgets/devices, and interiors. Subjects need not relate; the point is variety across the 30 slides. No brand marks or logos in the images.
Behavior notes
- Click-only, no scroll, no rAF. The only interactions are clicks on the 30 nav segments. Clicking the already-active segment is a no-op.
- Responsive (
max-width: 900px):.slider-navwidens towidth:40%;.slide .imggrows towidth:80%; height:75%;.slide-titlebecomesleft:47.5%; height:25%and its second row resets toleft:0;.letter spanfont-size drops from280pxto100px. - The bar's segment-widening is a CSS transition (
750ms cubic-bezier(0,0.75,0.5,1)), intentionally slightly out of sync with the1.5sGSAP"hop"slide/color tweens. - Colors are random each click, so no two sessions look identical; only the letter darkening (
filter … !important) keeps the type readable over the shifting wash.