All components

Barrett & Hale Scroll Animation

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

Open live demo ↗ Raw prompt (.md)

What it does

A fixed center title bar ('Barrett & Hale') with mix-blend-mode difference sits over a scrolling client index. As each row of client names passes the bar's center, two scrubbed ScrollTriggers animate the row's flex `gap` so the names spread apart then snap back together. Reaching the tall footer section, further ScrollTriggers scrub the bar's `top` from 50% to 92% and scale its font-size from 1.25vw to 9vw over a sticky full-bleed photograph.

How it's built

Categoryscroll
Techgsap
GSAP pluginsScrollTrigger
Complexitypage
Performance costlight
Mobile-safeyes

scroll scrolltrigger scrub flex-gap mix-blend-mode typography editorial minimal

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

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:

  1. .sticky-bar — a flex row of three .item blocks, each holding a <p>:
  2. item 1 <p>Barrett</p>
  3. item 2 <p>&</p>
  4. item 3 <p>Hale</p>
  5. section.hero — one full-bleed <img> (hero image).
  6. section.clients16 .row divs, each containing exactly two .logo divs, each .logo holding a <p> placeholder (Logo 1Logo 32). 32 logo cells total.
  7. 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> are text-transform: uppercase, base font-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 is flex: 1 and text-align:center; item 3 is text-align:right. So the layout is Barrett (left) … & (center) … Hale (right).
  • .sticky-bar .item p { color:#fff; font-size: 1.25vw; } (white so difference inverts 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 initial gap is 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: true
  • start: () => \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; set row.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: true
  • start: () => \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; set row.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: true
  • start: "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; set stickyBar.style.top = \${newTop}%\``.
  • Net: the fixed title bar's top scrubs 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: true
  • start: () => \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-bar via stickyBar.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: difference against 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):

  1. Hero — full-screen opening image at the top of the page.
  2. 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 <900px branches 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: none on html, body prevents scroll chaining/bounce.
  • Wrap all setup in DOMContentLoaded and register ScrollTrigger before creating triggers so offsetTop/offsetHeight measurements are valid.