Icon-to-Text Pinned Scroll Story (GSAP + Lenis)
Goal
Build a full-screen pinned hero section driven by a single scrubbed ScrollTrigger: a bottom row of five app icons rises with a staggered catch-up motion, gathers and shrinks to the viewport center while the background flips from dark to light, then the icons are cloned and fly one-by-one along an L-shaped path (vertical, then horizontal) into inline placeholder slots inside a big headline, whose text segments finally fade in one at a time in a shuffled random order.
Tech
Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the GSAP plugin ScrollTrigger, and lenis for smooth scrolling. No other libraries. Wrap all JS in a DOMContentLoaded listener and call gsap.registerPlugin(ScrollTrigger).
Layout / HTML
<section class="hero">containing:<div class="hero-header">with an<h1>"MotionpromptsPRO" and a<p>"One subscription, endless web design."<div class="animated-icons">with five children<div class="animated-icon icon-1">…icon-5, each wrapping an<img>(the five icon images).<h1 class="animated-text">containing an interleaved sequence of inline elements, in this exact order:<div class="placeholder-icon"></div><span class="text-segment">Delve into coding</span><div class="placeholder-icon"></div><span class="text-segment">without clutter.</span><span class="text-segment">Unlock source code </span><div class="placeholder-icon"></div><span class="text-segment">for every tutorial</span><div class="placeholder-icon"></div><span class="text-segment">published on the Motionprompts</span><div class="placeholder-icon"></div><span class="text-segment">YouTube channel.</span>
That is 6 text segments and 5 placeholders total.
<section class="outro">with an<h1>"Link in description".
Styling
- Google Font "Host Grotesk" on
body(fallback sans-serif). Global reset (* { margin:0; padding:0; box-sizing:border-box }). img { width:100%; height:100%; object-fit:cover; }h1 { font-size:7vw; font-weight:800; line-height:1; },p { font-size:1.5rem; font-weight:400; }section:position:relative; width:100vw; height:100svh; padding:1.5rem; display:flex; align-items:center; justify-content:center; background-color:#141414; color:#e3e3db; overflow:hidden;.hero:flex-direction:column;and cruciallytransition: background-color 0.3s ease;(the JS swaps its background between#141414and#e3e3db)..hero-header:position:absolute; top:35%; left:50%; transform:translate(-50%,-50%); width:60%; text-align:center; display:flex; flex-direction:column; gap:2rem; will-change:transform,opacity;.animated-icons:position:fixed; bottom:1rem; left:1rem; right:1rem; display:flex; align-items:center; gap:1rem; z-index:2; will-change:transform;— a full-width row pinned to the bottom of the viewport..animated-icon:flex:1; aspect-ratio:1;(five equal squares filling the row)..animated-text:position:relative; max-width:1000px; text-align:center; color:#141414; font-size:clamp(2rem,5vw,4rem); font-weight:800; line-height:1;— note the text is DARK (#141414), so it is invisible against the dark background and only becomes readable once the background flips to light..text-segment { opacity:0; }.placeholder-icon:display:inline-block; width:60px; height:60px; margin-top:-10px; vertical-align:middle; visibility:hidden; will-change:transform;— invisible but reserves inline space inside the headline where the flying icons will land.- Media query
@media (max-width:1000px):h1 { font-size:12vw; text-align:center; },p { font-size:1.1rem; },.hero-header { top:45%; width:100%; },.placeholder-icon { width:30px; height:30px; margin-top:-4px; }.
GSAP effect (exhaustive)
Lenis setup
const lenis = new Lenis();
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => lenis.raf(time * 1000));
gsap.ticker.lagSmoothing(0);
Pre-computed values (once, on load)
- Collect the 6
.text-segmentelements into an array of{ segment, originalIndex }objects and shuffle it with a Fisher–Yates shuffle (random per page load). This shuffled order drives the final text reveal. isMobile = window.innerWidth <= 1000;headerIconSize = isMobile ? 30 : 60(px — matches the placeholder CSS size).currentIconSize =width of the first.animated-iconviagetBoundingClientRect().exactScale = headerIconSize / currentIconSize— the scale factor that makes a row icon exactly placeholder-sized.
ScrollTrigger
One single ScrollTrigger, no timelines or tweens — everything is set imperatively with gsap.set inside onUpdate, mapped from self.progress (fully deterministic and reversible):
ScrollTrigger.create({
trigger: ".hero",
start: "top top",
end: `+=${window.innerHeight * 8}px`, // pinned for 8 viewport heights
pin: true,
pinSpacing: true,
scrub: 1,
onUpdate: (self) => { ... }
});
At the top of every onUpdate, first gsap.set every .text-segment to opacity: 0 (they are re-revealed only in phase 4). Then branch on progress:
Phase 1 — icons rise (progress 0 → 0.3)
moveProgress = progress / 0.3.- Header fade: for
progress <= 0.15,headerProgress = progress / 0.15; set.hero-headertotransform: translate(-50%, calc(-50% + ${-50 * headerProgress}px))andopacity: 1 - headerProgress(fades up 50px and out during the first half of the phase). For0.15 < progress <= 0.3, hold it attranslate(-50%, calc(-50% + -50px)),opacity: 0. - If clone icons from a later phase exist (stored on
window.duplicateIcons), remove them from the DOM and null the reference (this makes scrolling back up clean). - Container:
gsap.set(".animated-icons", { x: 0, y: -window.innerHeight * 0.3 * moveProgress, scale: 1, opacity: 1 })— the whole row travels upward by 30% of the viewport height across the phase. - Staggered catch-up per icon: for each of the 5
.animated-icons (indexi): staggerDelay = i * 0.1; the icon's active window is[staggerDelay, staggerDelay + 0.5]withinmoveProgress.iconProgress = gsap.utils.mapRange(iconStart, iconEnd, 0, 1, moveProgress), clamped to [0, 1].- Set the icon's individual
yto(-containerMoveY) * (1 - clampedProgress)wherecontainerMoveYis the container's current negative y. Effect: each icon starts offset downward exactly canceling the container's upward motion, then catches up in sequence — leftmost first — creating a wave.
Phase 2 — gather to center + theme flip (progress 0.3 → 0.6)
scaleProgress = (progress - 0.3) / 0.3.- Keep the header held at
-50px,opacity: 0. - Background flip: when
scaleProgress >= 0.5setheroSection.style.backgroundColor = "#e3e3db"(light), else"#141414"(dark). The CSS transition makes it a smooth 0.3s crossfade. This flip reveals the dark headline text color context for later. - Remove
window.duplicateIconsif present (same cleanup as phase 1). - Move the container to the viewport center: measure the container's live
getBoundingClientRect()center each update, computedeltaX/deltaYto the viewport center (innerWidth/2,innerHeight/2), multiply byscaleProgress, and set{ x: deltaX, y: -window.innerHeight * 0.3 + deltaY, scale: 1 + (exactScale - 1) * scaleProgress, opacity: 1 }. So the row shrinks from full width down to placeholder icon size while drifting to the exact center. - Reset each individual icon to
{ x: 0, y: 0 }.
Phase 3 — clones fly into the headline (progress 0.6 → 0.75)
moveProgress = (progress - 0.6) / 0.15.- Header stays hidden; background stays light (
#e3e3db). - Park the real container at the exact viewport center (full deltas,
scale: exactScale) but withopacity: 0— it is hidden and replaced by clones. - Create clones once (guard with a
window.duplicateIconsnull-check): for each.animated-icon,cloneNode(true), classduplicate-icon,position: absolute, width/heightheaderIconSizepx, appended todocument.body; store them inwindow.duplicateIcons. - Each of the 5 clones flies from its source icon's current center (from
getBoundingClientRect()+window.pageXOffset/pageYOffset, i.e. page coordinates) to the center of the matching.placeholder-icon(same index, also in page coordinates). The path is an L-shape driven bymoveProgress: - First half (
moveProgress <= 0.5): move vertically only —currentY = moveY * (moveProgress / 0.5),currentX = 0. - Second half: vertical done (
currentY = moveY), move horizontally —currentX = moveX * ((moveProgress - 0.5) / 0.5). - Position each clone with
style.left/top = final page coords - headerIconSize / 2(centered),opacity: 1,display: flex.
Phase 4 — text reveal (progress 0.75 → 1)
- Header held at
translate(-50%, calc(-50% + -100px)),opacity: 0; background light; real icon containeropacity: 0. - Snap every clone exactly onto its placeholder's center (recomputed each update so it tracks layout).
- Shuffled per-segment fade-in: iterate the shuffled array; for shuffled position
r, the segment's window issegmentStart = 0.75 + r * 0.03,segmentEnd = segmentStart + 0.015. Map globalprogressthroughgsap.utils.mapRange(segmentStart, segmentEnd, 0, 1, progress), clamp to [0, 1], and set it as the segment'sopacity. Result: the six headline fragments pop in quickly (each over 1.5% of scroll), one after another, in random order, finishing around progress 0.9.
Assets / images
Five square (1:1) app-style icons on colored tiles, used in order in the bottom row:
- Green rounded-square tile with a black interlocking pinwheel / eight-point star mark.
- Yellow circular badge with a black abstract triangular "A" mark made of two angled strokes over a bar.
- Blue rounded-square tile with a black yin-yang-style droplet spiral inside a thin ring.
- Orange circular badge with four black rounded squares arranged in a diamond/plus layout.
- Periwinkle rounded-square tile with a black rounded frame containing a 2×2 grid of circles.
Any set of five colorful, flat, app-icon-style square images works — they just need to read clearly at both large (row) and small (60px inline) sizes.
Behavior notes
- The whole effect is scrub-driven state (
gsap.setfromprogress), so scrolling backwards fully reverses it; the clone cleanup in phases 1–2 guarantees no orphaned duplicates when scrubbing back. - The random segment order is shuffled once per page load, so each visit reveals the headline words in a different order.
- Mobile (≤1000px) uses 30px placeholders/clones instead of 60px and the adjusted typography above; everything else is identical.
- The outro section provides the scroll runway exit after the pin ends.