All components

SeventeenAgency Scroll Animation

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

Open live demo ↗ Raw prompt (.md)

What it does

Infinite-scrolling contact page (Lenis infinite mode) where the contact-info section is cloned 10x into a seamless loop: two scrubbed ScrollTriggers per row animate its CSS gap from 1rem to 10rem and back as the row crosses the viewport center, while a fixed vignetted still at screen center cycles through seven captured-light images each time a new row locks onto the center.

How it's built

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

scroll infinite lenis scrub contact-page gap icon-swap loop clone

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

Infinite-Scroll Contact Page — Center-Crossing Gap "Breathing" + Icon Cycling

Goal

Build a full-screen, endlessly looping contact page. A tall list of contact rows scrolls vertically forever (seamless infinite loop). As each row passes through the vertical center of the viewport, the horizontal gap between its label and value expands then snaps back, giving a "breathing" pulse to whichever row is centered. Simultaneously, a single 3D icon fixed at the exact center of the screen swaps to the next shape every time a new row locks onto center. That center-crossing gap pulse synced with the icon swap is the star effect.

Tech

Vanilla HTML/CSS/JS with ES module imports. Build as a fresh Vite project (npm create vite, vanilla template). Install and import from npm:

  • gsap and the GSAP plugin ScrollTrigger (import { ScrollTrigger } from "gsap/ScrollTrigger"), registered via gsap.registerPlugin(ScrollTrigger).
  • lenis for smooth scrolling, used in infinite mode (new Lenis({ infinite: true })).

No other plugins (no SplitText, no CustomEase, no Three.js). All row animation is driven by ScrollTrigger.onUpdate callbacks that write inline styles — no gsap tweens on the rows.

Layout / HTML

Two top-level sections inside <body>:

  1. <section class="contact-visual"> — the fixed centered stage. Contains a single

<div class="contact-icon"> wrapping one <img> (the cycling icon; src points to the first icon asset, alt="").

  1. <section class="contact-info"> — the scrolling content. It holds 8 rows, each a

<div class="contact-info-row"> containing exactly two <p> elements: a right-aligned label and a muted value. Sample content (fictional demo studio — keep as-is, no real brands):

  • Address / 19 Great Street
  • Current Time / 20:40:30 (GMT)
  • General Inquiries / hello@deadspacelabs.com
  • New Business Inquiries / business@deadspacelabs.com
  • Collaborations / Selective Connections
  • Job Inquiries / hiring@deadspacelabs.com
  • Telephone / +44 09 9842 2235
  • Social Media / @Deadspace

The script clones the .contact-info section to create the tall loop (see effect section).

Styling

  • Font: import Stylish from Google Fonts (@import url("https://fonts.googleapis.com/css2?family=Stylish&display=swap")); body { font-family: "Stylish", sans-serif }.
  • Palette (CSS vars): --base-100: #fff (text), --base-200: #4f4f4f (muted value text),

--base-300: #0f0f0f (page background). Body background = --base-300, color = --base-100.

  • Reset: * { margin:0; padding:0; box-sizing:border-box }. Hide the scrollbar entirely

(::-webkit-scrollbar { display:none }).

  • Images: img { width:100%; height:100%; object-fit:cover }.
  • Paragraphs: font-size: 1.4rem; font-weight: 400; line-height: 0.95; letter-spacing: -0.025rem.
  • .contact-visual: position: fixed; top:0; left:0; width:100%; height:100svh; flexbox

centered (justify-content:center; align-items:center); overflow:hidden. It sits on top of the scrolling content and never moves.

  • .contact-icon: position: relative; width: 8rem; height: 8rem; (the icon is 128×128).
  • .contact-info: position: relative; width:100%; height:100svh; flex column,

justify-content:center; gap:1.5rem; overflow:hidden. Each cloned copy is exactly one viewport tall so rows are evenly distributed down the page.

  • .contact-info-row: display:flex; justify-content:center; gap:1rem; will-change:gap;

(the gap is what animates — hint it for perf).

  • Row paragraphs: .contact-info-row p { flex: 1 }; first <p> (:nth-child(1)) is

text-align:right; second <p> (:nth-child(2)) uses color: var(--base-200).

  • Responsive @media (max-width:1000px): p { font-size: 1rem } and

.contact-icon { width:4rem; height:4rem }.

GSAP effect (be precise — this is the core)

0. Smooth-scroll + infinite loop plumbing
const lenis = new Lenis({ infinite: true });
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => lenis.raf(time * 1000));
gsap.ticker.lagSmoothing(0);

infinite: true makes the page wrap seamlessly — scrolling past the end returns to the start with no visible seam, so the row/icon animation runs forever in both directions.

1. Clone the content into a tall column
  • Read const contactRowMaxGap = window.innerWidth < 1000 ? 5 : 10; (max gap in rem; 10 on

desktop, 5 on mobile).

  • Take the single .contact-info section and clone it 10 times, appending each deep clone

(cloneNode(true)) to its parent (<body>). Result: 11 identical .contact-info sections stacked (1 original + 10 clones), i.e. 88 total .contact-info-row elements. This gives the infinite scroll enough length to loop smoothly.

  • After cloning, query all rows: const contactRows = document.querySelectorAll(".contact-info-row").
2. Per-row gap pulse (two scrubbed ScrollTriggers each — the breathing effect)

Compute the vertical center of the fixed stage with a helper re-read on every refresh:

function getVisualCenter() {
  return contactVisual.offsetTop + contactVisual.offsetHeight / 2;
}

(.contact-visual is fixed at top:0, so this ≈ half the viewport height.)

For each of the 88 rows, create two ScrollTriggers (no tweens — pure onUpdate callbacks writing row.style.gap). Both use functional start/end so they recompute on refresh, scrub: true, and the scroller-position keyword center (the viewport's vertical middle):

Trigger A — EXPAND (gap 1rem → maxGap):

  • trigger: row
  • start: () => \top+=${getVisualCenter() - 550} center\``
  • end: () => \top+=${getVisualCenter() - 450} center\``
  • scrub: true
  • onUpdate: (self) => { const gap = 1 + (contactRowMaxGap - 1) * self.progress; row.style.gap = \${gap}rem\; }
  • Effect: over a 100px scroll band as the row rises toward center, gap interpolates linearly

from 1rem up to maxGap (10rem desktop / 5rem mobile).

Trigger B — CONTRACT (gap maxGap → 1rem):

  • trigger: row
  • start: () => \top+=${getVisualCenter() - 400} center\``
  • end: () => \top+=${getVisualCenter() - 300} center\``
  • scrub: true
  • onUpdate: (self) => { const gap = contactRowMaxGap - (contactRowMaxGap - 1) * self.progress; row.style.gap = \${gap}rem\; }
  • Effect: over the next 100px band, gap interpolates linearly from maxGap back down to 1rem.

Notes on the band geometry: the four hardcoded pixel offsets are -550, -450, -400, -300 relative to getVisualCenter(). So relative to the moment the row reaches center there is: a 100px expand window, a ~50px hold at max gap (between the -450 end of A and the -400 start of B), then a 100px contract window. The net feel: each row's two columns fan apart as the row nears the screen center and slam back together just after — a rhythmic pulse row after row. Because it's scrubbed, it's fully reversible and tied 1:1 to scroll position/velocity (no ease, linear by self.progress).

3. Center-locked icon cycling (swap the fixed 3D icon)

Target const contactIcon = document.querySelector(".contact-icon img"). Keep two state vars: let currentIconIndex = 1; and let lastCenteredRow = null;.

On lenis.on("scroll", ...) (a second scroll listener), each frame:

const viewportCenter = window.innerHeight / 2;
let closestRow = null, minDistance = Infinity;
contactRows.forEach((row) => {
  const rect = row.getBoundingClientRect();
  const rowCenter = rect.top + rect.height / 2;
  const distance = Math.abs(rowCenter - viewportCenter);
  if (distance < minDistance && distance < 25) { minDistance = distance; closestRow = row; }
});
if (closestRow && closestRow !== lastCenteredRow) {
  lastCenteredRow = closestRow;
  currentIconIndex = (currentIconIndex % 7) + 1;      // cycle 1→2→…→7→1
  contactIcon.src = `/path/to/icon_${currentIconIndex}.png`;
}

So: whenever a new row comes within 25px of the exact viewport center, advance the icon to the next of 7 shapes (wrapping 7→1) and set the <img> src. The icon change is a hard image swap (no crossfade), tightly synchronized with each row hitting center — which is also the moment that row's gap pulse peaks.

Assets / images

7 files (icon_1.pngicon_7.png), all PNG on a fully transparent background, square 1:1 aspect ratio, displayed at 128×128 (8rem). They are glossy 3D-rendered "Y2K / chrome" glyphs, each a distinct figurative sculpture with strong specular highlights and iridescent edge reflections. The set holds 6 unique subjects (icon_7.png is a duplicate of icon_1.png):

  1. icon_1.png / icon_7.png — a chrome/liquid-metal robotic hand and forearm reaching

up, mirror-silver body with hot pink, red and violet iridescent streaks. This is the one shown on load.

  1. icon_2.png — a furry/fuzzy hollow diamond (rhombus) outline, dominant vivid orange

fur with lighter yellow-orange highlights.

  1. icon_3.png — a glossy chrome smiley-face outline (two oval eyes + a smile), purple and

magenta metallic finish with pink and blue reflections on black.

  1. icon_4.png — an inflated, balloon-like glossy bubble letter "W", deep electric blue /

indigo chrome with white specular highlights.

  1. icon_5.png — a translucent wireframe globe / gridded sphere (latitude + longitude

lines), iridescent green-to-teal with faint pink chromatic fringing.

  1. icon_6.png — a multi-pointed 3D star pierced by an orbiting ring, purple-to-magenta

chrome with blue highlights. No brand marks; the figurative shapes are decorative studio glyphs. Reference each as icon_${n}.png from your assets path.

Behavior notes

  • Desktop + mobile: the only responsive change is contactRowMaxGap (10 vs 5 rem, chosen once

at load by window.innerWidth < 1000) plus the CSS font-size/icon-size media query.

  • The effect is scroll-driven and infinite — it never idles; with Lenis infinite: true the

content wraps forever, so both the gap pulse and icon cycling repeat indefinitely as you scroll either direction.

  • Order matters: clone first, then query .contact-info-row, then create the ScrollTriggers so

every cloned row is animated.

  • The .contact-visual icon layer is position: fixed and stays dead-center; only its <img> src

changes.