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:
gsapand the GSAP pluginScrollTrigger(import { ScrollTrigger } from "gsap/ScrollTrigger"), registered viagsap.registerPlugin(ScrollTrigger).lenisfor 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>:
<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="").
<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 StreetCurrent Time/20:40:30 (GMT)General Inquiries/hello@deadspacelabs.comNew Business Inquiries/business@deadspacelabs.comCollaborations/Selective ConnectionsJob Inquiries/hiring@deadspacelabs.comTelephone/+44 09 9842 2235Social Media/@Deadspace
The script clones the .contact-info section to create the tall loop (see effect section).
Styling
- Font: import
Stylishfrom 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-infosection 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: rowstart: () => \top+=${getVisualCenter() - 550} center\``end: () => \top+=${getVisualCenter() - 450} center\``scrub: trueonUpdate: (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: rowstart: () => \top+=${getVisualCenter() - 400} center\``end: () => \top+=${getVisualCenter() - 300} center\``scrub: trueonUpdate: (self) => { const gap = contactRowMaxGap - (contactRowMaxGap - 1) * self.progress; row.style.gap = \${gap}rem\; }- Effect: over the next 100px band, gap interpolates linearly from
maxGapback down to1rem.
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.png … icon_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):
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.
icon_2.png— a furry/fuzzy hollow diamond (rhombus) outline, dominant vivid orange
fur with lighter yellow-orange highlights.
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.
icon_4.png— an inflated, balloon-like glossy bubble letter "W", deep electric blue /
indigo chrome with white specular highlights.
icon_5.png— a translucent wireframe globe / gridded sphere (latitude + longitude
lines), iridescent green-to-teal with faint pink chromatic fringing.
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: truethe
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-visualicon layer isposition: fixedand stays dead-center; only its<img>src
changes.