All components

Assembly Icon-to-Text Scroll Animation

GSAP animation component · Published 2026-07-27 · by vanguardia.dev

Open live demo ↗ Raw prompt (.md)

What it does

A pinned hero for the 'Assembly' creative workspace, driven by a single scrubbed ScrollTrigger (smoothed with Lenis). As you scroll, the wordmark fades out, a bottom row of five flat app tiles (built as inline SVG) rises with a staggered offset, gathers to center while scaling down, the background flips from near-black to warm paper, the tiles are cloned and flown one-by-one into inline placeholders inside a headline, and the headline words fade in one at a time in a shuffled order.

How it's built

Categoryscroll
Techgsap, lenis
GSAP pluginsScrollTrigger
Complexitypage
Performance costlight
Mobile-safeyes
Scrollhijacks scrolling

scroll pin scrub lenis stagger icon-morph theme-switch text-reveal flip

Rebuild it with AI

To reproduce this animation in your own project, copy the prompt below into Claude Code, Cursor or any AI coding agent. The prompt is validated — it describes the exact structure, timing and easing, so the agent rebuilds the effect faithfully and you can then adapt colors, copy and layout to your design.

The full prompt

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 crucially transition: background-color 0.3s ease; (the JS swaps its background between #141414 and #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-segment elements 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-icon via getBoundingClientRect().
  • 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-header to transform: translate(-50%, calc(-50% + ${-50 * headerProgress}px)) and opacity: 1 - headerProgress (fades up 50px and out during the first half of the phase). For 0.15 < progress <= 0.3, hold it at translate(-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 (index i):
  • staggerDelay = i * 0.1; the icon's active window is [staggerDelay, staggerDelay + 0.5] within moveProgress.
  • iconProgress = gsap.utils.mapRange(iconStart, iconEnd, 0, 1, moveProgress), clamped to [0, 1].
  • Set the icon's individual y to (-containerMoveY) * (1 - clampedProgress) where containerMoveY is 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.5 set heroSection.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.duplicateIcons if present (same cleanup as phase 1).
  • Move the container to the viewport center: measure the container's live getBoundingClientRect() center each update, compute deltaX/deltaY to the viewport center (innerWidth/2, innerHeight/2), multiply by scaleProgress, 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 with opacity: 0 — it is hidden and replaced by clones.
  • Create clones once (guard with a window.duplicateIcons null-check): for each .animated-icon, cloneNode(true), class duplicate-icon, position: absolute, width/height headerIconSizepx, appended to document.body; store them in window.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 by moveProgress:
  • First half (moveProgress <= 0.5): move vertically onlycurrentY = moveY * (moveProgress / 0.5), currentX = 0.
  • Second half: vertical done (currentY = moveY), move horizontallycurrentX = 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 container opacity: 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 is segmentStart = 0.75 + r * 0.03, segmentEnd = segmentStart + 0.015. Map global progress through gsap.utils.mapRange(segmentStart, segmentEnd, 0, 1, progress), clamp to [0, 1], and set it as the segment's opacity. 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:

  1. Green rounded-square tile with a black interlocking pinwheel / eight-point star mark.
  2. Yellow circular badge with a black abstract triangular "A" mark made of two angled strokes over a bar.
  3. Blue rounded-square tile with a black yin-yang-style droplet spiral inside a thin ring.
  4. Orange circular badge with four black rounded squares arranged in a diamond/plus layout.
  5. 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.set from progress), 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.