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.:
Field Notes/Breathing Video Experience/Creative DesignNocturne/Nocturne Noir Show/EventHalcyon/Halcyon x Atlas/Creative ConceptGrid/Grid Seven Years/Art DirectionPulse/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 here —
p { 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; }— theoverflow: hiddenis 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, .tagdivs, 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 attop: 0%(visible), child 2 attop: 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 defaultpower1.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.25stransition 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:
- Create two
<img>(img1→.preview-img-1,img2→.preview-img-2), bothsrc = src. - Set each image's initial inline
clip-pathto a polygon collapsed onto the bottom edge (zero height, invisible):
polygon(0% 100%, 100% 100%, 100% 100%, 0% 100%).
- 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`.
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 everymouseover(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
.menumouseout. - No re-entrancy guards — GSAP naturally overwrites in-flight tweens when the pointer moves quickly between rows.
- No console errors; nothing scrolls.