Award List Hover — direction-aware row flip + stacked corner image preview
Goal
Build a full-page awards list where hovering a row slides a 3-panel wrapper vertically (GSAP y tween) so the row flips from an award-name panel to an inverted project panel — the slide direction depends on whether the cursor enters/leaves from the top or the bottom of the row. Simultaneously, a fixed preview box in the bottom-right corner stacks up that row's image, scaling it in from 0; images scale back out and are removed when the cursor leaves the list or idles.
Tech
Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) and lenis (npm) for smooth scrolling. No GSAP plugins are needed (no ScrollTrigger — scroll handling is a manual listener). Everything runs inside DOMContentLoaded.
Layout / HTML
<body>
<section class="intro"><h1>Intro</h1></section>
<section class="awards">
<p>Recognition and awards</p>
<div class="awards-list"></div>
</section>
<section class="outro"><h1>Outro</h1></section>
<div class="award-preview"></div>
<script type="module" src="./script.js"></script>
</body>
The rows are generated in JS from a data array of 17 award objects, each with 4 strings: name, type, project, label. Use neutral portfolio-style copy, e.g. { name: "Site of the day", type: "Awwwards", project: "Open Field Audio", label: "See Live" }, a first row like { name: "Independent of the year", type: "Nominee", project: "INNOVATE 2024", label: "Awwwards" }, and several { name: "Developer Award", ... } rows with varied project names (invented, no real brands).
For each award object append to .awards-list:
<div class="award">
<div class="award-wrapper">
<div class="award-name"><h1>{name}</h1><h1>{type}</h1></div>
<div class="award-project"><h1>{project}</h1><h1>{label}</h1></div>
<div class="award-name"><h1>{name}</h1><h1>{type}</h1></div>
</div>
</div>
Note the wrapper holds three stacked 80px panels (name / project / duplicated name) = 240px total, clipped to an 80px-tall row.
Styling
- Reset:
* { margin: 0; padding: 0; box-sizing: border-box; }. body: background#e3e3db; a bold modern grotesque sans-serif (e.g."Saans", "Helvetica Neue", Arial, sans-serif).h1: uppercase,font-size: 72px,font-weight: 800,letter-spacing: -1px,line-height: 0.9.p: uppercase,1.5rem, weight 700..awards pgetspadding: 5px 20px.section:position: relative; width: 100vw; height: 100vh; overflow: hidden;..introand.outroare flex-centered..awards:min-height: 100vh; height: max-content;..awards-list:border-top: 1px solid #000..award:height: 80px;clipped withclip-path: polygon(0 0, 100% 0, 100% 100%, 0% 100%)(a full-rect clip that hides the overflowing wrapper)..award-wrapper:position: relative; height: 240px; will-change: transform; transform: translateY(-160px);— initial state shows the bottom (duplicate) name panel..award-name,.award-project:width: 100%; height: 80px; display: flex; justify-content: space-between; align-items: center; padding: 5px 15px; cursor: pointer; border-bottom: 1px solid #000;..award-name: background#e3e3db, color#000..award-project: inverted — background#000, color#e3e3db..award-preview:position: fixed; bottom: 15px; right: 15px; width: 30%; height: 30%; z-index: 2;.- Global
img:position: absolute; width: 100%; height: 100%; object-fit: cover; will-change: transform;.
GSAP effect (exhaustive)
Initialize Lenis smooth scroll: new Lenis({ autoRaf: true }).
Define three wrapper positions (px for the y transform): BOTTOM = 0 (shows top name panel), MIDDLE = -80 (shows project panel), TOP = -160 (shows bottom name panel — the resting state).
Keep module-level state: lastMousePosition {x,y}, activeAward (element or null), ticking (rAF flag), mouseTimeout, isMouseMoving. Each row also keeps its own currentPosition, initialized to TOP.
Every tween in this component uses duration: 0.4, ease: "power2.out", via gsap.to. There are no timelines, delays or staggers.
1. Row mouseenter
- Set
activeAward = thisRow. - Compute
enterFromTop = e.clientY < rect.top + rect.height / 2(rect = row'sgetBoundingClientRect()). - If
enterFromToporcurrentPosition === BOTTOM: setcurrentPosition = MIDDLEand tween the row's.award-wrappertoy: -80(duration 0.4, power2.out). (Entering from the bottom while resting atTOPintentionally does nothing — the wrapper stays put.) - Create a new
<img>for this row:src = ./img{index+1}.jpg(row index 0 →img1.jpg… row 16 →img17.jpg). Style it inline:position: absolute; top: 0; left: 0;, initialscale: 0(element style), andzIndex = Date.now()so newer images always stack on top. Append it to.award-preview, then tween it withgsap.to(img, { scale: 1, duration: 0.4, ease: "power2.out" }). A fresh image is appended on every enter, even re-hovers of the same row, so images pile up.
2. Row mouseleave
- Set
activeAward = null. leavingFromTop = e.clientY < rect.top + rect.height / 2.currentPosition = leavingFromTop ? TOP : BOTTOM, then tween the wrapper to thaty(-160or0), 0.4s power2.out. So leaving downward rolls the project panel up out of view (name panel slides in from below), and leaving upward rolls it down.
3. Document mousemove
- Update
lastMousePositionfrome.clientX/clientY; setisMouseMoving = trueand clear any pendingmouseTimeout. - If the cursor is inside the
.awards-listbounding rect, arm a 2000mssetTimeout: when it fires (mouse idle), setisMouseMoving = falseand, if.award-previewholds more than one<img>, keep only the last one — every other image tweensscale: 0(0.4s, power2.out) and is removed withonComplete: () => img.remove(). - Then call
animatePreview(): iflastMousePositionis outside the.awards-listrect (any side), tween all images in.award-previewtoscale: 0(0.4s, power2.out) and remove eachonComplete.
4. Scroll handling (hover state survives scrolling)
Add a passive document scroll listener with a rAF ticking guard: on scroll, requestAnimationFrame(updateAwards) once per frame. updateAwards():
- Calls
animatePreview()(clears the preview stack if the cursor rect-check fails after scrolling). - If there is an
activeAward, re-check whetherlastMousePositionis still inside its current bounding rect; if not, tween its wrapper toTOP(-160) when the cursor is above the row's vertical midpoint, else toBOTTOM(0) — 0.4s power2.out — and clearactiveAward. - Loop all rows (skipping the active one): if
lastMousePositionnow falls inside a row's rect (the page scrolled under a stationary cursor), tween that wrapper toMIDDLE(-80), 0.4s power2.out, and mark it asactiveAward. - Reset
ticking = false.
Assets / images
17 editorial photographs (img1.jpg … img17.jpg), one per row, loaded relative to the page. They display inside a corner box that is 30% of viewport width × 30% of viewport height, cropped with object-fit: cover, so any roughly landscape (≈3:2 / 16:10) imagery works — e.g. moody architecture, product or portrait shots with a coherent palette. No logos or brand marks.
Behavior notes
- Desktop / mouse-driven only; there is no touch fallback (fine to leave as-is).
- The preview images intentionally accumulate as a stack while the user keeps moving over rows; cleanup happens only on the 2s idle timeout (keep last) or when the cursor exits the list bounds (remove all).
scaleon the preview images is animated via GSAP'sscaletransform (initial0set via the element's inlinescalestyle).- No console errors; the page scrolls normally (intro → awards → outro) with Lenis smoothing.