All components

Interactive Team Section

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

Open live demo ↗ Raw prompt (.md)

What it does

A row of small monochrome team thumbnails sits above a giant clip-masked headline. Hovering a thumbnail scales it up (power4.out) while GSAP slides that member's name into view, animating the SplitText characters vertically with a center-out stagger; hovering the whole row reveals the default 'Nine of us' title.

How it's built

Categoryhover
Techgsap
GSAP pluginsSplitText
Complexitysection
Performance costlight
Mobile-safedesktop-first

hover team split-text stagger clip-path gsap reveal editorial typography

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

Interactive Team Section — hover thumbnails reveal giant staggered names

Goal

Build a full-viewport "meet the team" section: a centered row of 9 small square portrait thumbnails sits above a giant clip-masked headline area. Hovering a thumbnail smoothly enlarges it (70px → 140px) while that member's name slides up into view as huge red condensed type, animated character-by-character with a center-out SplitText stagger. When the cursor enters the row at all, a default title ("The Squad") animates in the same way in an off-white color; leaving the row hides it again.

Tech

Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the GSAP plugin SplitText (import { SplitText } from "gsap/SplitText", then gsap.registerPlugin(SplitText)). No other libraries, no smooth-scroll.

Layout / HTML

<section class="team">
  <div class="profile-images">
    <div class="img"><img src="..." alt="" /></div>   <!-- x9 -->
  </div>
  <div class="profile-names">
    <div class="name default"><h1>The Squad</h1></div>
    <div class="name"><h1>Colin</h1></div>
    <div class="name"><h1>Liam</h1></div>
    <div class="name"><h1>Tabitha</h1></div>
    <div class="name"><h1>Tyson</h1></div>
    <div class="name"><h1>Max</h1></div>
    <div class="name"><h1>Everest</h1></div>
    <div class="name"><h1>Simon</h1></div>
    <div class="name"><h1>Gideon</h1></div>
    <div class="name"><h1>Benton</h1></div>
  </div>
</section>
  • Exactly 9 .img thumbnails and 10 .name blocks: the first one has the extra class default and holds the group title; the remaining 9 map 1:1 (in order) to the 9 thumbnails.
  • Load the JS with <script type="module" src="./script.js">.

Styling

  • Global reset: * { margin: 0; padding: 0; box-sizing: border-box; }. All img elements: width: 100%; height: 100%; object-fit: cover;.
  • Google Font: Barlow Condensed (import the full weight range; weight 900 is the one used).
  • .team: position: relative; width: 100vw; height: 100svh; background-color: #0f0f0f; color: #e3e3db; flex column, justify-content: center; align-items: center; gap: 2.5em; overflow: hidden;.
  • .profile-images: width: max-content; flex row, centered items.
  • .img: position: relative; width: 70px; height: 70px; padding: 5px; cursor: pointer; will-change: width, height; — the padding creates the gutter between thumbnails, so animating width/height grows the tile in place. Inner img { border-radius: 0.5rem; }.
  • .profile-names: width: 100%; height: 15rem; overflow: hidden; plus clip-path: polygon(0 0, 100% 0, 100% 100%, 0% 100%); (a full-rect clip that hard-masks the sliding text).
  • .name h1: position: absolute; width: 100%; text-align: center; text-transform: uppercase; font-family: "Barlow Condensed"; font-size: 15rem; font-weight: 900; letter-spacing: -0.2rem; line-height: 1; color: #f93535; user-select: none; transform: translateY(100%); — every heading is absolutely stacked in the same spot and parked one full line below the mask.
  • .name.default h1: overrides color: #e3e3db; and transform: translateY(-100%); — the default title is parked one full line above instead.
  • .name h1 .letter (class added by JS to each SplitText char): position: relative; transform: translateY(0%); will-change: transform;.
The reveal mechanism (critical)

Each h1 keeps a static CSS translateY offset (+100% for member names, −100% for the default) and is never animated itself. GSAP only animates the letters inside. Because the letters are position: relative children, their y transform adds to the parent's: letters at y: -100% inside a +100% parent net out to 0 → the name is visible; letters at y: 0% leave it hidden below. For the default title it's mirrored: letters at y: 100% inside a −100% parent net to 0 → visible (this is the initial state, so "The Squad" shows on load); letters at y: 0% hide it above.

GSAP effect (exhaustive)

Everything runs inside DOMContentLoaded. Triggers are pure hover (mouseenter/mouseleave); no ScrollTrigger, no timelines — independent gsap.to tweens.

  1. SplitText setup (all 10 headings): new SplitText(heading, { type: "chars" }) on every .name h1; add the class letter to every resulting char element (split.chars.forEach(char => char.classList.add("letter"))).
  2. Initial state: gsap.set the default title's letters (.name.default.letter) to y: "100%". Combined with the parent's CSS -100% this makes "The Squad" visible on load.
  3. Desktop gate: wrap all event wiring in if (window.innerWidth >= 900) — below 900px there are no listeners and no animation at all.
  4. Per-thumbnail hover (index i pairs with name i+1, skipping the default):
  5. mouseenter on .img:
  6. Tween the .img wrapper: width: 140, height: 140, duration: 0.5, ease: "power4.out" (grows from 70×70 to 140×140, pushing siblings apart).
  7. Tween that member's letters: y: "-100%", duration: 0.75, ease: "power4.out", stagger: { each: 0.025, from: "center" } → the name rises into the mask, characters unfolding from the middle outward.
  8. mouseleave on .img:
  9. Tween the wrapper back: width: 70, height: 70, duration: 0.5, ease: "power4.out".
  10. Tween the letters back: y: "0%", duration: 0.75, ease: "power4.out", stagger: { each: 0.025, from: "center" } → the name sinks back below the mask.
  11. Row-level hover (default title):
  12. mouseenter on .profile-images container: tween the default letters to y: "0%", duration: 0.75, ease: "power4.out", stagger: { each: 0.025, from: "center" } → "The Squad" slides up and out of view (so a member name can take the stage).
  13. mouseleave on the container: tween them back to y: "100%" with the same duration/ease/stagger → "The Squad" drops back into view.
  14. Note these container events fire alongside the per-thumbnail ones (entering a thumbnail also means entering the container), which is exactly what produces the crossfade-like swap: default title exits upward while the hovered name enters from below.

Assets / images

9 square (1:1) editorial studio portraits — tight head-and-shoulders shots of different people against solid, saturated color-block backgrounds (warm reds, oranges, ochres, sage/olive greens, teal), consistent fashion-editorial lighting. One per thumbnail, in row order. Any cohesive square portrait set works; no logos or brand marks.

Behavior notes

  • Desktop-only interaction: below 900px viewport width no listeners are attached; the section renders statically with "The Squad" visible.
  • Responsive layout at max-width: 900px: .team becomes flex-direction: column-reverse (names above the images), .profile-images wraps (flex-wrap: wrap; max-width: 90%;), .img shrinks to 60px × 60px with 2.5px padding, .profile-names height drops to 4rem, and h1 becomes font-size: 4rem; letter-spacing: 0;.
  • No loops, no scroll hijacking; overlapping tweens on quick hover swaps are fine (GSAP's default overwrite behavior handles it).