All components

Vanguardia Award List Hover

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

Open live demo ↗ Raw prompt (.md)

What it does

An awards list where hovering each row slides a three-layer wrapper via gsap.to (y translate, power2.out) so the row flips between the award panel and a cobalt project/year panel depending on whether the cursor enters from the top or bottom, while a fixed corner preview stacks the row’s image scaling it in and out with GSAP for a cursor-driven hover reveal.

How it's built

Categoryhover
Techgsap, lenis
Complexitypage
Performance costlight
Mobile-safedesktop-first
Scrollhijacks scrolling

hover list image-preview gsap lenis cursor awards 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

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 p gets padding: 5px 20px.
  • section: position: relative; width: 100vw; height: 100vh; overflow: hidden;. .intro and .outro are flex-centered. .awards: min-height: 100vh; height: max-content;.
  • .awards-list: border-top: 1px solid #000.
  • .award: height: 80px; clipped with clip-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's getBoundingClientRect()).
  • If enterFromTop or currentPosition === BOTTOM: set currentPosition = MIDDLE and tween the row's .award-wrapper to y: -80 (duration 0.4, power2.out). (Entering from the bottom while resting at TOP intentionally 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;, initial scale: 0 (element style), and zIndex = Date.now() so newer images always stack on top. Append it to .award-preview, then tween it with gsap.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 that y (-160 or 0), 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 lastMousePosition from e.clientX/clientY; set isMouseMoving = true and clear any pending mouseTimeout.
  • If the cursor is inside the .awards-list bounding rect, arm a 2000ms setTimeout: when it fires (mouse idle), set isMouseMoving = false and, if .award-preview holds more than one <img>, keep only the last one — every other image tweens scale: 0 (0.4s, power2.out) and is removed with onComplete: () => img.remove().
  • Then call animatePreview(): if lastMousePosition is outside the .awards-list rect (any side), tween all images in .award-preview to scale: 0 (0.4s, power2.out) and remove each onComplete.
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():

  1. Calls animatePreview() (clears the preview stack if the cursor rect-check fails after scrolling).
  2. If there is an activeAward, re-check whether lastMousePosition is still inside its current bounding rect; if not, tween its wrapper to TOP (-160) when the cursor is above the row's vertical midpoint, else to BOTTOM (0) — 0.4s power2.out — and clear activeAward.
  3. Loop all rows (skipping the active one): if lastMousePosition now falls inside a row's rect (the page scrolled under a stationary cursor), tween that wrapper to MIDDLE (-80), 0.4s power2.out, and mark it as activeAward.
  4. Reset ticking = false.

Assets / images

17 editorial photographs (img1.jpgimg17.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).
  • scale on the preview images is animated via GSAP's scale transform (initial 0 set via the element's inline scale style).
  • No console errors; the page scrolls normally (intro → awards → outro) with Lenis smoothing.