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
.imgthumbnails and 10.nameblocks: the first one has the extra classdefaultand 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; }. Allimgelements: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. Innerimg { border-radius: 0.5rem; }..profile-names:width: 100%; height: 15rem; overflow: hidden;plusclip-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: overridescolor: #e3e3db;andtransform: 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.
- SplitText setup (all 10 headings):
new SplitText(heading, { type: "chars" })on every.name h1; add the classletterto every resulting char element (split.chars.forEach(char => char.classList.add("letter"))). - Initial state:
gsap.setthe default title's letters (.name.default→.letter) toy: "100%". Combined with the parent's CSS-100%this makes "The Squad" visible on load. - Desktop gate: wrap all event wiring in
if (window.innerWidth >= 900)— below 900px there are no listeners and no animation at all. - Per-thumbnail hover (index i pairs with name i+1, skipping the default):
mouseenteron.img:- Tween the
.imgwrapper:width: 140, height: 140, duration: 0.5, ease: "power4.out"(grows from 70×70 to 140×140, pushing siblings apart). - 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. mouseleaveon.img:- Tween the wrapper back:
width: 70, height: 70, duration: 0.5, ease: "power4.out". - 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. - Row-level hover (default title):
mouseenteron.profile-imagescontainer: tween the default letters toy: "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).mouseleaveon the container: tween them back toy: "100%"with the same duration/ease/stagger → "The Squad" drops back into view.- 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:.teambecomesflex-direction: column-reverse(names above the images),.profile-imageswraps (flex-wrap: wrap; max-width: 90%;),.imgshrinks to60px × 60pxwith2.5pxpadding,.profile-namesheight drops to4rem, andh1becomesfont-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).