All components

YARD Hover Menu

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

Open live demo ↗ Raw prompt (.md)

What it does

A large uppercase project list where hovering a menu item swaps its label lines via GSAP top-position tweens and reveals a stacked image preview that follows the cursor. The preview images grow in and out with an animated clip-path polygon on a power3.out ease, while the trailing cursor position is smoothed with a gsap.to on mousemove.

How it's built

Categoryhover
Techgsap
Complexitysection
Performance costlight
Mobile-safedesktop-first

hover-menu clip-path image-preview cursor-follow text-swap gsap 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

Editorial Hover Menu — text roll-swap rows + cursor-trailing stacked clip-path image preview

Goal

Build a full-width list of large uppercase project rows. Hovering the whole list dims every row's text to grey; hovering an individual row rolls its text upward and swaps in an identical black duplicate line (the active row snaps to black, the rest stay grey). At the same time a small stacked image-preview card follows the cursor with lag and the hovered row's thumbnail wipes into view from the bottom via an animated clip-path polygon. Leaving the list wipes all previews upward and out. Everything is hover/mousemove-driven — no scroll, no click, no autoplay.

Tech

Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) only — no GSAP plugins, no ScrollTrigger, no smooth-scroll library. Single import: import gsap from "gsap";. All logic runs inside a DOMContentLoaded listener.

Layout / HTML

<body>
  <div class="container">
    <div class="preview">
      <div class="preview-img preview-img-1"></div>
      <div class="preview-img preview-img-2"></div>
    </div>
    <div class="menu">
      <div class="menu-item">
        <div class="info"><p>Field Notes</p></div>
        <div class="name"><p>Breathing Video Experience</p></div>
        <div class="tag"><p>Creative Design</p></div>
      </div>
      <!-- 4 more .menu-item rows -->
    </div>
  </div>
  <script type="module" src="./script.js"></script>
</body>

5 menu-item rows. Each row has exactly three label divs in this order: .info (short left label), .name (big centred project title), .tag (short right-aligned label), each wrapping a single <p>. The two .preview-img divs start empty — images are injected by JS. Use neutral, fictional editorial-studio copy (no real brand names), e.g.:

  1. Field Notes / Breathing Video Experience / Creative Design
  2. Nocturne / Nocturne Noir Show / Event
  3. Halcyon / Halcyon x Atlas / Creative Concept
  4. Grid / Grid Seven Years / Art Direction
  5. Pulse / Pulse Music Battle / Direction

Styling

  • Reset: * { margin: 0; padding: 0; box-sizing: border-box; }. Background stays default white, text default black.
  • img { width: 100%; height: 100%; object-fit: cover; }.
  • .container { width: 100%; height: 100%; }.
  • .menu { width: 100%; margin: 17.5em 0; } (large top/bottom gap so the rows sit centred).
  • .menu-item { width: 100%; padding: 0 2em; display: flex; cursor: pointer; } — a flex row.
  • Font: p { font-family: "PP Neue Montreal", "Neue Montreal", "Helvetica Neue", Arial, sans-serif; font-weight: 500; text-transform: uppercase; line-height: 100%; letter-spacing: -0.025em; }. Any tight modern grotesque works; uppercase + tight tracking is the look.
  • The roll mechanism lives herep { position: absolute; top: 0%; width: 100%; transition: color 0.25s; }. Each label div gets a second <p> appended by JS; the second one sits below, off-view:

``css .info p:nth-child(2), .name p:nth-child(2), .tag p:nth-child(2) { top: 100%; color: #000; } ``

  • .info, .tag, .name { position: relative; overflow: hidden; } — the overflow: hidden is what clips the rolling text to a single-line window.
  • .info, .tag { flex: 1; height: 14px; font-size: 14px; } and .tag { text-align: right; }.
  • .name { flex: 4; height: 55px; font-size: 60px; text-align: center; } (note the 55px clip window is slightly shorter than the 60px type — intentional).
  • Menu-wide dim on hover (pure CSS): while the pointer is anywhere over the list, every row's *first* line goes grey; the black duplicate only appears when a specific row rolls:

``css .menu:hover .info p:nth-child(1), .menu:hover .name p:nth-child(1), .menu:hover .tag p:nth-child(1) { color: rgb(165, 165, 165); } ``

  • Preview card:
  • .preview { position: absolute; top: 0; left: 0; width: 225px; height: 275px; z-index: 2; pointer-events: none; } (a portrait ~4:5 box, non-interactive; JS drives its position via transforms).
  • .preview-img { position: absolute; width: 100%; height: 100%; }.
  • .preview-img-2 { top: 20px; left: 20px; } — the second layer is offset +20px down/right and paints on top (later in DOM), so the two layers read as a stacked/duplicated card.
  • .preview-img img { position: absolute; top: 0; left: 0; } (so stacked images overlap exactly within each layer).

GSAP effect (exhaustive)

All animation is GSAP gsap.to. No timelines, no plugins.

0. Setup on DOMContentLoaded
  • Define an array of 5 image sources (portrait thumbnails), one per row, in row order.
  • Duplicate every label line. For each .menu-item, for each of its .info, .name, .tag divs, read its <p>, create a new <p> with the *same* textContent, and append it. Now each label div has two identical <p>s: child 1 at top: 0% (visible), child 2 at top: 100% (below, black #000).
1. Text roll-swap — row mouseover / mouseout

Bind mouseover and mouseout on each .menu-item.

mouseOverAnimation(row) — roll the current lines up and the duplicates in:

gsap.to(row.querySelectorAll("p:nth-child(1)"), { top: "-100%", duration: 0.3 });
gsap.to(row.querySelectorAll("p:nth-child(2)"), { top:  "0%",  duration: 0.3 });

mouseOutAnimation(row) — reverse it:

gsap.to(row.querySelectorAll("p:nth-child(1)"), { top: "0%",   duration: 0.3 });
gsap.to(row.querySelectorAll("p:nth-child(2)"), { top: "100%", duration: 0.3 });
  • Both ps in a div animate together (info + name + tag of that row roll as one). duration: 0.3, no explicit ease → GSAP default power1.out. No stagger, no delay.
  • Because child 1 is CSS-greyed (menu is hovered) and child 2 is black, the visible effect is: the grey line slides up and out while the identical black line slides up into place — the active row turns black, others stay grey. On mouseout it rolls back to the grey line (with the CSS color 0.25s transition easing the recolor).
2. Image preview reveal — appendImages(src) (called on each row mouseover)

On mouseover, in addition to the text roll, inject and reveal the row's thumbnail into both preview layers:

  1. Create two <img> (img1.preview-img-1, img2.preview-img-2), both src = src.
  2. Set each image's initial inline clip-path to a polygon collapsed onto the bottom edge (zero height, invisible):

polygon(0% 100%, 100% 100%, 100% 100%, 0% 100%).

  1. Append them, then reveal both together — the top edge lifts from the bottom to full-rect:

``js gsap.to([img1, img2], { clipPath: "polygon(0% 100%, 100% 100%, 100% 0%, 0% 0%)", // full rectangle duration: 1, ease: "power3.out", onComplete: () => { removeExtraImages(preview1); removeExtraImages(preview2); }, }); ` So each thumbnail wipes up from the bottom over 1s power3.out`.

  1. removeExtraImages(container): while (container.children.length > 10) container.removeChild(container.firstChild); — keep at most 10 images per layer, dropping the oldest. A fresh pair is appended on every mouseover (including re-hovering the same row), so rapidly sweeping across rows stacks reveals; the newest reveals on top.
3. Wipe-out on leaving the list — .menu mouseout
gsap.to(".preview-img img", {
  clipPath: "polygon(0% 0%, 100% 0%, 100% 0%, 0% 0%)", // collapsed onto the top edge
  duration: 1,
  ease: "power3.out",
});

Every preview image (all layers, all stacked copies) wipes upward and out through the top over 1s power3.out when the cursor leaves the whole menu.

4. Cursor-trailing preview — document mousemove
document.addEventListener("mousemove", (e) => {
  gsap.to(".preview", {
    x: e.clientX + 300,
    y: e.clientY,
    duration: 1,
    ease: "power3.out",
  });
});

The .preview card chases the pointer with heavy lag (1s power3.out per move), offset +300px on X (it trails to the right of the cursor) and tracks Y directly. Because .preview starts at top:0; left:0 and is moved only by GSAP x/y transforms, it flies in from the top-left on first movement.

Assets / images

5 portrait photographic project thumbnails (~4:5 / 225×275), one per row, loaded in row order. Editorial/creative-studio imagery — moody stills, performance/event shots, art-direction frames — with a coherent palette; cropped with object-fit: cover. No logos or brand marks. (If fewer fixtures exist than rows, repeat them in order.)

Behavior notes

  • Desktop / mouse-only. The whole effect is hover + mousemove; there is no touch or keyboard fallback (leave as-is).
  • Preview images intentionally accumulate as a stack (capped at 10 per layer); there is no per-row cleanup — old images are only dropped by the 10-cap or wiped out on .menu mouseout.
  • No re-entrancy guards — GSAP naturally overwrites in-flight tweens when the pointer moves quickly between rows.
  • No console errors; nothing scrolls.