Sticky Title Bar over Scrolling Logo List — Gap-Spread + Scale Scroll Animation
Goal
Build a single-page editorial scroll experience. A fixed, vertically-centered title bar reading "Barrett & Hale" is painted with mix-blend-mode: difference so it inverts against everything scrolling behind it. As each row of a long client-logo list crosses the bar's center line, the row's two logos spread apart and then snap back together (animated flex gap, driven by scrub ScrollTriggers). At the end of the page a very tall black footer section scrolls in; while it does, the title bar drifts downward from vertical-center to near-bottom and its type scales up massively (from tiny to huge). The star effect is the scrubbed flex-gap spread as rows pass the sticky bar.
Tech
Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the GSAP plugin ScrollTrigger. No smooth-scroll library (native browser scroll). No SplitText, no CustomEase, no Three.js, no canvas. Entry: <script type="module" src="./script.js">, import gsap from "gsap" and import { ScrollTrigger } from "gsap/ScrollTrigger", then gsap.registerPlugin(ScrollTrigger) inside a DOMContentLoaded handler.
Layout / HTML
Single .container wrapping, in order:
.sticky-bar— a flex row of three.itemblocks, each holding a<p>:- item 1
<p>Barrett</p> - item 2
<p>&</p> - item 3
<p>Hale</p> section.hero— one full-bleed<img>(hero image).section.clients— 16.rowdivs, each containing exactly two.logodivs, each.logoholding a<p>placeholder (Logo 1…Logo 32). 32 logo cells total.section.trigger-footer— one full-bleed<img>(footer image).
The class names .sticky-bar, .row, .trigger-footer and the .sticky-bar p elements are load-bearing for the JS — keep them exact.
Styling
- Reset:
* { margin:0; padding:0; box-sizing:border-box }.html, body { width:100%; height:100%; overscroll-behavior:none }. - Font family: a modern neo-grotesque sans-serif —
font-family: "PP Neue Montreal"(fall back to"Neue Montreal", Helvetica, Arial, sans-serif). All<p>aretext-transform: uppercase, basefont-size: 18px,font-weight: 600. img { width:100%; height:100%; object-fit:cover }..sticky-bar:position: fixed; width:100%; top:50%; transform: translateY(-50%); padding:1em; display:flex; mix-blend-mode: difference;— this blend mode is essential (the title inverts over the white logo section, the images, and the black footer).- Flex distribution: item 1 and item 3 are
flex: 2; item 2 isflex: 1andtext-align:center; item 3 istext-align:right. So the layout isBarrett(left) …&(center) …Hale(right). .sticky-bar .item p { color:#fff; font-size: 1.25vw; }(white sodifferenceinverts it).section.hero:width:100vw; height:100vh(full opening viewport)..clients:width:100%; padding:10em 1em; background:#fff;..row:width:100%; display:flex; justify-content:center; gap:1em;(this initialgapis what the JS animates)..logo p:font-size:40px;.section.trigger-footer:width:100%; height:300vh; background:#000;— deliberately 3× viewport tall so the closing animations have a long scrub runway.- Mobile (
@media (max-width:900px)):.sticky-bar .item p { font-size:2.5vw },.logo p { font-size:20px }.
Color palette: white #fff (logo section + title text), black #000 (footer). No other colors; the imagery supplies the rest.
GSAP effect (exhaustive)
All animations are ScrollTrigger scrub: true triggers that do not use gsap.to/timelines — each one manually mutates inline styles inside its onUpdate(self) using self.progress (0→1) and linear interpolation value = start + (end - start) * progress. There are four families of triggers.
Compute a helper once: getStickyBarCenter() = stickyBar.offsetTop + stickyBar.offsetHeight / 2 (the fixed bar's vertical center in px; ~half the viewport height). Also read footerTriggerHeight = trigger-footer.offsetHeight (= 300vh in px). All start/end values are written as functions (() => \…\``) so they recompute on refresh/resize.
1. Row gap — SPREAD APART (one trigger per .row)
For every .row:
trigger: row,scrub: truestart: () => \top+=${getStickyBarCenter() - 550} center\``end: () => \top+=${getStickyBarCenter() - 450} center\``onUpdate:maxGap = innerWidth < 900 ? 10 : 15;minGap = innerWidth < 900 ? 0.5 : 1;currentGap = minGap + (maxGap - minGap) * progress; setrow.style.gap = \${currentGap}em\``.- Net: over this ~100px scroll window the gap grows 1em → 15em (desktop) / 0.5em → 10em (mobile) — the two logos push apart as the row approaches the bar's center line.
2. Row gap — SNAP BACK TOGETHER (one trigger per .row)
For every .row (a second, separate trigger):
trigger: row,scrub: truestart: () => \top+=${getStickyBarCenter() - 400} center\``end: () => \top+=${getStickyBarCenter() - 300} center\``onUpdate:maxGap = innerWidth < 900 ? 0.5 : 1;minGap = innerWidth < 900 ? 10 : 15;currentGap = minGap + (maxGap - minGap) * progress; setrow.style.gap.- Because here
minGap = 15,maxGap = 1, the interpolation runs 15em → 1em (desktop) / 10em → 0.5em (mobile) — the logos collapse back together just after the row crosses the bar.
Combined behavior of #1 + #2: as a row scrolls up past the fixed center title, its gap goes 1 → 15 → (holds) → 15 → 1 em. The offsets are measured from the row's own top: the point center−550 above the row hits the viewport center to start the spread, center−450 ends it (fully spread), then center−400 starts the collapse and center−300 finishes it. Between −450 and −400 the gap sits at max. Result: each row "opens" as it reaches the bar and "closes" as it leaves — like the title bar splitting the two logos apart while it passes through them.
3. Footer — sticky bar DRIFTS DOWN (single trigger)
trigger: trigger-footer,scrub: truestart: "top bottom"(footer top reaches viewport bottom)end: () => \top+=${footerTriggerHeight - window.innerHeight} center\`(i.e.2 * innerHeight` below the footer top reaches viewport center)onUpdate:newTop = 50 + (92 - 50) * progress; setstickyBar.style.top = \${newTop}%\``.- Net: the fixed title bar's
topscrubs 50% → 92%, sliding it from vertical-center down toward the bottom as the black footer scrolls in.
4. Footer — title type SCALES UP (single trigger)
trigger: trigger-footer,scrub: truestart: () => \top+=${footerTriggerHeight - (window.innerHeight + 100)} bottom\``end: "bottom bottom"(footer bottom reaches viewport bottom)onUpdate:fontSizeStart = innerWidth < 900 ? 2.5 : 1.25;fontSizeEnd = 9;newFontSize = fontSizeStart + (fontSizeEnd - fontSizeStart) * progress; apply to every<p>inside.sticky-barviastickyBar.querySelectorAll("p").forEach(p => p.style.fontSize = \${newFontSize}vw\).- Net: during the final stretch of the tall footer the title font-size scrubs 1.25vw → 9vw (desktop) / 2.5vw → 9vw (mobile) — "Barrett & Hale" balloons into a giant headline, still inverted via
mix-blend-mode: differenceagainst the black footer (so it reads bright).
Timing/ordering: #1 and #2 fire continuously and independently per row throughout the logo section (16 rows → 16 + 16 triggers). #3 and #4 only engage once the tall footer enters/passes. Everything is scrub-linked to scroll position — there is no autoplay, no duration, no ease, no stagger, no delay; the "ease" is the linear progress interpolation and the browser's scroll.
Assets / images
Two full-bleed editorial photographs, each meant to fill the viewport (~16:9 landscape / full-screen crop, object-fit: cover):
- Hero — full-screen opening image at the top of the page.
- Footer — closing image inside the tall black footer trigger section.
Use neutral, high-contrast editorial photography (fashion/architectural mood works well since the title inverts over it). No brand marks. If only one image is available, reuse it for both slots.
Behavior notes
- Desktop and mobile both supported; the
<900pxbranches only change the gap min/max (10/0.5 vs 15/1) and the title start font-size (2.5vw vs 1.25vw).mobileSafe. - Light performance cost: no WebGL/canvas/physics; only inline-style writes on scroll.
- The effect depends on the footer being 300vh and the logo section being long (16 rows) — keep those proportions so the scrub windows have room.
overscroll-behavior: noneonhtml, bodyprevents scroll chaining/bounce.- Wrap all setup in
DOMContentLoadedand registerScrollTriggerbefore creating triggers sooffsetTop/offsetHeightmeasurements are valid.