All components

Merle Photo-Studio Hover Index

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

Open live demo ↗ Raw prompt (.md)

What it does

A dark full-screen index for a photography & art-direction studio where hovering a series in the left list crossfades a full-bleed background image and reveals a positioned project preview (image, title, tags, description). GSAP tweens a clip-path wipe on the preview image plus staggered x/y + opacity transforms driven by per-item layout variants, all triggered on mouseenter/mouseleave.

How it's built

Categoryhover
Techgsap
Complexitypage
Performance costmedium
Mobile-safeyes

hover gsap clip-path image-reveal crossfade portfolio dark minimal interactive

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

Thetalab — Hover Preview Landing

Goal

Build a dark, full-screen studio/portfolio landing page. The left third of the screen holds a vertical list of 9 project names (pill chips). Hovering a name crossfades a full-bleed background photo AND reveals a floating "preview card" for that project — a portrait image that wipes open via an animated clip-path, plus a large title, a tags line, and a short description that each slide into place from different directions. Each project uses one of 3 rotating layout variants that place the card pieces in different corners and make the image wipe open from a different edge. Moving off the list crossfades everything back to a default muted background.

Tech

Vanilla HTML/CSS/JS with ES module imports, bundled by Vite. Use gsap (npm) only — no GSAP plugins are needed (the clip-path polygon is tweened by GSAP's built-in CSSPlugin because both keyframes share the same 4-point count). No smooth-scroll / Lenis (the page never scrolls). No canvas, no WebGL.

import gsap from "gsap";

Layout / HTML

Everything lives inside one fixed, overflow: hidden, 100vw×100vh black container. Static markup:

  • .container (fixed, background:#000, overflow hidden)
  • nav — three equal flex columns (nav > div { flex:1 }):
  • .menu-btn<p>Menu</p> (a frosted pill, left aligned)
  • .logo<a href="#">Thetalab</a> (centered)
  • .local-time<p>ON 11:34AM</p> (right aligned)
  • footer — two items, space-between: <p>Watch Showreel</p> and <p>Collection 2024</p>
  • .items — the left-third project list, containing 9 .item blocks, each <div class="item"><p>NAME</p></div> with names, in order:

Ra, Uptask, Genix, Unor, Maxim Stark, Pitcher, MindSpace, The Athletix, All Work

  • .preview-bg — the background image layer; starts with a single <img> pointing at the default background.

The preview cards are built in JS (not in the static HTML) from a data array, and appended into .container. Each generated card is:

<div class="preview variant-N preview-{i}">
  <div class="preview-img"><img src="{thumb}" alt=""/></div>
  <div class="preview-title"><h1>{title}</h1></div>
  <div class="preview-tags"><p>{tags}</p></div>
  <div class="preview-description"><p>{description}</p></div>
</div>

Card data (index → title / tags / description / variant / images). Variants cycle 1,2,3,1,2,3,1,2,3:

| # | title | tags | description | variant | |---|-------|------|-------------|---------| | 1 | Ra | Futuristic Fashion, Minimal Design | Exploring the intersection of minimalism and future fashion trends in web design. | variant-1 | | 2 | Uptask | Fashion Innovation, Graphic Simplicity | Innovative fashion-forward web design with a core focus on simplicity and elegance. | variant-2 | | 3 | Genix | Eco-Fashion, Sustainable Design | Sustainable fashion meets modern web aesthetics, highlighting eco-friendly apparel innovations. | variant-3 | | 4 | Unor | Digital Fashion, UI/UX | Digital-first fashion branding that merges cutting-edge UI/UX principles with style. | variant-1 | | 5 | Maxim Stark | Futuristic Wearables, Web Graphics | Futuristic wearables and gear presented in a sleek, graphically rich web interface. | variant-2 | | 6 | Pitcher | Innovative Textiles, Web Presentation | Web presentation of innovative textiles that redefine the boundaries of modern fashion. | variant-3 | | 7 | MindSpace | Augmented Reality, Fashion Design | Augmented reality in fashion design, creating immersive web experiences for users. | variant-1 | | 8 | The Athletix | Virtual Fashion, Graphic Design | Virtual fashion collections displayed through stunning graphic design and web aesthetics. | variant-2 | | 9 | All Work | Minimalist Aesthetics, Fashion Forward | Fashion forward web design encapsulating the essence of minimalist aesthetics and innovation. | variant-3 |

Item i (0-based) maps to card preview-(i+1), background image bg-(i+1), and thumbnail main-(i+1).

Styling

  • Base: black #000, all text white #fff. Font family a neutral grotesque sans (design uses "Basis Grotesque Pro"; fall back to Inter / Helvetica Neue / system sans).
  • img { width:100%; height:100%; object-fit:cover; } globally.
  • nav: fixed, full width, padding:2em, flex align-center, z-index:2. nav p, footer pfont-size:12px.
  • .menu-btn p and .item p share a frosted-pill look: background:rgba(255,255,255,0.1), border-radius:40px, backdrop-filter:blur(20px), padding:6px 12px, width:max-content.
  • .logo a: font-size:20px; font-weight:500; text-decoration:none.
  • footer: fixed bottom, full width, padding:2em, flex space-between, z-index:2.
  • .items: fixed, width:30%, height:100vh, padding:0 2em, flex column, justify-content:center, z-index:2. .item: width:max-content; padding:0.25em 0; cursor:pointer. .item p also has font-weight:lighter; border:1px solid rgba(255,255,255,0.1); transition:0.3s. On .item:hover pbackground:none; border:1px solid rgba(255,255,255,0.25) (CSS-only pill hover state, separate from the GSAP effect).
  • .preview-bg: position:absolute; width:100%; height:100%; opacity:0.35 — this permanent 35% opacity is what mutes ALL background photos so overlaid text stays readable.
  • .preview: position:absolute; width:100vw; height:100vh; color:#fff; z-index:1 (sits above .preview-bg at z-index 0, below nav/footer/items at z-index 2). All cards are stacked full-screen; only their inner pieces are positioned/animated.
  • .preview-title h1: font-weight:lighter; font-size:80px. .preview-tags p, .preview-description p: font-size:14px; line-height:120%.
Variant positioning + collapsed clip-paths (this defines the wipe direction)

Each variant absolutely positions .preview-img (fixed 300px × 400px, margin:2em), .preview-title, .preview-tags, .preview-description (width:250px) differently, and gives .preview-img a collapsed starting clip-path:

  • variant-1 (image wipes UP from the bottom edge):
  • .preview-img: bottom:0; right:0; clip-path: polygon(0% 100%, 100% 100%, 100% 100%, 0% 100%)
  • .preview-title: top:35%; left:25% · .preview-tags: bottom:25%; right:40% · .preview-description: right:25%; top:25%
  • variant-2 (image wipes LEFT from the right edge):
  • .preview-img: top:10%; right:5%; clip-path: polygon(100% 0, 100% 0, 100% 100%, 100% 100%)
  • .preview-title: bottom:30%; left:50% · .preview-tags: top:25%; left:25% · .preview-description: right:10%; bottom:15%
  • variant-3 (image wipes RIGHT from the left edge):
  • .preview-img: bottom:10%; left:15%; clip-path: polygon(0% 0%, 0% 0%, 0% 100%, 0% 100%)
  • .preview-title: bottom:40%; right:15% · .preview-tags: bottom:20%; right:30% · .preview-description: left:20%; top:15%

GSAP effect (exhaustive)

Lookup tables (JS)
defaultClipPaths = {
  "variant-1": "polygon(0% 100%, 100% 100%, 100% 100%, 0% 100%)",  // collapsed to bottom line
  "variant-2": "polygon(100% 0, 100% 0, 100% 100%, 100% 100%)",     // collapsed to right line
  "variant-3": "polygon(0% 0%, 0% 0%, 0% 100%, 0% 100%)"            // collapsed to left line
}
// per-variant hidden offsets for the three text blocks (px), all opacity:0
variantTransforms = {
  "variant-1": { title:{x:75,opacity:0},  tags:{y:-75,opacity:0}, description:{x:-75,opacity:0} },
  "variant-2": { title:{x:-75,opacity:0}, tags:{y:-75,opacity:0}, description:{y:75,opacity:0}  },
  "variant-3": { title:{x:75,opacity:0},  tags:{y:75,opacity:0},  description:{x:75,opacity:0}  }
}

The revealed (full) image clip-path is always polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%) (full rectangle).

Initial state (on DOMContentLoaded, right after each card is created & appended)

For every card, read its variant and gsap.set() its .preview-title, .preview-tags, .preview-description to the corresponding variantTransforms offset + opacity:0. The .preview-img starts hidden purely from its CSS collapsed clip-path (no JS needed). Cards themselves keep opacity:1 by default but read as empty because the image is clipped to a line and the text is at opacity 0. Track activePreview (starts as none) and a boolean isMouseOverItem.

Background crossfade — changeBg(src)

Create a fresh <img>, absolutely fill it (top/left:0; width/height:100%; object-fit:cover), start at opacity:0, append into .preview-bg, then:

  • gsap.to(newImg, { opacity: 1, duration: 0.5 }) (default ease power1.out).
  • If .preview-bg now holds more than one child, fade the OLD (first) child: gsap.to(oldImg, { opacity: 0, duration: 0.5, onComplete: () => remove it }).

This yields a 0.5s dissolve between backgrounds (always seen through the layer's fixed 0.35 opacity).

On item.mouseenter (index i)

Fire these simultaneously (no timeline object; independent tweens, no stagger, no delay unless noted):

  1. isMouseOverItem = true.
  2. changeBg("bg-" + (i+1) + ".jpg") → the 0.5s background dissolve above.
  3. Resolve newActivePreview = .preview-(i+1).
  4. If a *different* activePreview was already showing, retract it first:
  5. gsap.to(prevImg, { clipPath: itsDefaultCollapsedClipPath, duration: 0.75, ease: "power3.out" }) — its image wipes closed toward its own edge.
  6. gsap.to(prevPreview, { opacity: 0, duration: 0.3, delay: 0.2 }).
  7. Instantly gsap.set() the previous card's title/tags/description back to their hidden variantTransforms offsets.
  8. gsap.to(newActivePreview, { opacity: 1, duration: 0.1 }); set activePreview = newActivePreview.
  9. Slide the three text blocks in — for each of .preview-title, .preview-tags, .preview-description:

gsap.to(el, { x: 0, y: 0, opacity: 1, duration: 0.5 }) (default ease power1.out). They travel from their variant offsets (±75px x or y) to rest, fading 0→1, all at once (0.5s).

  1. Open the image: gsap.to(activeImg, { clipPath: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", duration: 1, ease: "power3.out" }) — a 1s wipe from the collapsed edge line to the full rectangle. Direction per variant: v1 up, v2 leftward, v3 rightward.

Net timing per hover: bg dissolve 0.5s, text in 0.5s, image wipe 1s — all starting together; card opacity snaps in over 0.1s.

On item.mouseleave
  1. isMouseOverItem = false.
  2. Immediately gsap.set() the current activePreview's title/tags/description back to their hidden variantTransforms offsets (text snaps out with no tween).
  3. setTimeout(…, 10); inside, only if isMouseOverItem is still false (i.e. the cursor didn't jump straight to another item):
  4. changeBg("default-bg.jpg") → dissolve back to the muted default background.
  5. gsap.to(activePreview, { opacity: 0, duration: 0.1 }) to hide the card.
  6. gsap.to(activeImg, { clipPath: itsDefaultCollapsedClipPath, duration: 1, ease: "power3.out" }) — image wipes closed over 1s.
  7. Clear activePreview so the next hover starts clean.

(Moving directly from one item to another skips this reset because the new mouseenter sets isMouseOverItem=true before the 10ms timeout runs; the retract logic in step 4 of mouseenter handles the handoff instead.)

Assets / images

A cohesive, muted, editorial pool of interior-design / architecture / soft still-life photography (warm neutrals, moody light):

  • 9 portrait preview thumbnails, aspect ratio 3:4 (rendered at 300×400) — one per project card (main-1 … main-9).
  • 9 full-bleed landscape backgrounds (~16:9, cover) — one per project (bg-1 … bg-9); always shown through the 0.35 opacity layer.
  • 1 default full-bleed background (default-bg) — a moody architectural nook, shown before/after hovering.

No brand logos or client marks. Thumbnails and backgrounds can share the same visual family.

Behavior notes

  • Desktop / hover only. The whole experience is pointer-driven; there is no touch fallback and the page never scrolls (single fixed viewport).
  • No prefers-reduced-motion branch in the original.
  • Nav/footer/list sit at z-index:2 above the cards (z-index:1) and background (z-index:0), so they stay legible during every transition.