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 10 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-image: var(--sky), 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 10.itemblocks, each<div class="item"><p>NAME</p></div>with names, in order:
Casting, Hinterland, Material studies, Nightshade, Table service, Vertical city, Heirlooms, Panthera, Form, Iris
.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 | Casting | On set · Black & white | Production stills from casting week: one chair, one light, a grey infinity wall. | variant-1 | | 2 | Hinterland | Landscape · 35mm film | Dry hills and lone oaks between Volterra and the coast, shot in the last week of August. | variant-2 | | 3 | Material studies | Still life · Glass & stone | Marble, water and refracted light, photographed for a stone supplier's annual catalogue. | variant-3 | | 4 | Nightshade | Still life · Velvet | Garden tools on crushed velvet — a hardware commission treated like jewellery. | variant-1 | | 5 | Table service | Food · Ceramics | Stoneware and beechwood for the opening menu of a twelve-seat restaurant in Lyon. | variant-2 | | 6 | Vertical city | Architecture · Commission | Glass towers against a noon sky, documented over two summers for a Lisbon developer. | variant-3 | | 7 | Heirlooms | Jewellery · Editorial | Silver rings shot close and quiet for an antique dealer's first campaign. | variant-1 | | 8 | Panthera | Portrait · Studio | A black panther on seamless paper. Four hours, one keeper, eleven usable frames. | variant-2 | | 9 | Form | Objects · Clay | Unfired clay spheres in raking light — a study in shadow for a ceramics school. | variant-3 | | 10 | Iris | Botanical · Colour | Irises against oxblood walls: our standing love letter to saturated colour. | variant-1 |
The variant column is the array ["variant-1","variant-2","variant-3"] cycled over the ten items, so it wraps and item 10 lands back on variant-1 — two variant-1 cards in a row (10 and 1) is correct, not a mistake.
Item i (0-based) maps to card preview-(i+1), background image bg-(i+1), and thumbnail main-(i+1).
Styling
- Base: a sky gradient, not a flat colour, with white type over it and near-black type inside the glass chips:
``css :root { --sky-top: #2f8bff; --sky-bottom: #9cc8ff; --sky: linear-gradient(180deg, var(--sky-top), var(--sky-bottom)); --ink: #0d0d0d; /* type inside the glass pills */ --ink-sky: #ffffff; /* type over the sky */ --ink-dim: #0b2545; /* deep blue for secondary copy */ --lime: #c6f21e; /* the single accent */ --card: #ffffff; --glass: rgba(255, 255, 255, 0.72); --glass-edge: rgba(13, 13, 13, 0.12); } ``
- Fonts: Inter for body, Space Grotesk for the big preview titles.
img { width:100%; height:100%; object-fit:cover; }globally.nav: fixed, full width,padding:2em, flex align-center,z-index:2.nav p,footer p→font-size:12px..menu-btn pand.item pshare 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, flexspace-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 pis a glass pill:color: var(--ink); padding:7px 15px; font-size:13px; background: var(--glass); border:1px solid var(--glass-edge); border-radius:40px; backdrop-filter: blur(20px); transition:0.3s— the hover state only shifts those values (CSS-only, 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: var(--ink-sky); z-index:1(sits above.preview-bgat 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-family:"Space Grotesk"; font-weight:600; font-size:clamp(56px, 6.5vw, 84px); line-height:1.02; letter-spacing:-0.03em; color: var(--ink-sky); text-shadow: 0 1px 24px rgba(15,60,130,.25)..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.
> .preview.default — the selector you will find in the source and must not chase. The > reference implementation seeds activePreview with document.querySelector(".preview.default") > and, on mouseleave, looks it up again to fade it back in. There is no such element. No card > is authored with a default class: the ten cards are generated as > preview variant-N preview-{i}, and the static markup holds only .preview-bg. So both lookups > return null, activePreview starts as null, and the gsap.to(null, …) in the mouseleave > branch is a no-op — GSAP ignores null targets silently. > > The behaviour that ships is therefore exactly what this section describes: no card on load, no > card at rest. The "default" state is the muted default-bg.jpg background with an empty stage > over it. Do not build a tenth, always-on .preview.default card to satisfy the selector — you > would be adding a card the demo never shows. Either drop the selector entirely (cleanest) or keep > it verbatim for fidelity, knowing it resolves to nothing. If your framework throws on null targets > where GSAP shrugs — React refs, a strict animation wrapper — guard both call sites; that is the > one place this dead selector can actually bite you.
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 easepower1.out).- If
.preview-bgnow 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):
isMouseOverItem = true.changeBg("bg-" + (i+1) + ".jpg")→ the 0.5s background dissolve above.- Resolve
newActivePreview = .preview-(i+1). - If a *different*
activePreviewwas already showing, retract it first: gsap.to(prevImg, { clipPath: itsDefaultCollapsedClipPath, duration: 0.75, ease: "power3.out" })— its image wipes closed toward its own edge.gsap.to(prevPreview, { opacity: 0, duration: 0.3, delay: 0.2 }).- Instantly
gsap.set()the previous card's title/tags/description back to their hiddenvariantTransformsoffsets. gsap.to(newActivePreview, { opacity: 1, duration: 0.1 }); setactivePreview = newActivePreview.- 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).
- 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
isMouseOverItem = false.- Immediately
gsap.set()the currentactivePreview's title/tags/description back to their hiddenvariantTransformsoffsets (text snaps out with no tween). setTimeout(…, 10); inside, only ifisMouseOverItemis still false (i.e. the cursor didn't jump straight to another item):changeBg("default-bg.jpg")→ dissolve back to the muted default background.gsap.to(activePreview, { opacity: 0, duration: 0.1 })to hide the card.gsap.to(activeImg, { clipPath: itsDefaultCollapsedClipPath, duration: 1, ease: "power3.out" })— image wipes closed over 1s.- Clear
activePreviewso 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 editorial pool of photography — studio interiors, landscape, architecture and abstract still life — shot cool or neutral so it sits inside the blue sky ground rather than fighting it:
- 10 portrait preview thumbnails, aspect ratio 3:4 (rendered at 300×400) — one per project card (
main-1 … main-10). - 10 full-bleed landscape backgrounds (~16:9, cover) — one per project (
bg-1 … bg-10); 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-motionbranch in the original. - Nav/footer/list sit at
z-index:2above the cards (z-index:1) and background (z-index:0), so they stay legible during every transition.
Images
This component ships with 21 reference assets, served publicly. Use them as-is to reproduce the demo faithfully, then swap in your own — the layout expects the same aspect ratios.
https://motionprompts.dev/c/metalab-lp-rebuild/bg-1.jpg
https://motionprompts.dev/c/metalab-lp-rebuild/bg-10.jpg
https://motionprompts.dev/c/metalab-lp-rebuild/bg-2.jpg
https://motionprompts.dev/c/metalab-lp-rebuild/bg-3.jpg
https://motionprompts.dev/c/metalab-lp-rebuild/bg-4.jpg
https://motionprompts.dev/c/metalab-lp-rebuild/bg-5.jpg
https://motionprompts.dev/c/metalab-lp-rebuild/bg-6.jpg
https://motionprompts.dev/c/metalab-lp-rebuild/bg-7.jpg
https://motionprompts.dev/c/metalab-lp-rebuild/bg-8.jpg
https://motionprompts.dev/c/metalab-lp-rebuild/bg-9.jpg
https://motionprompts.dev/c/metalab-lp-rebuild/default-bg.jpg
https://motionprompts.dev/c/metalab-lp-rebuild/main-1.jpg
https://motionprompts.dev/c/metalab-lp-rebuild/main-2.jpg
https://motionprompts.dev/c/metalab-lp-rebuild/main-3.jpg
https://motionprompts.dev/c/metalab-lp-rebuild/main-4.jpg
https://motionprompts.dev/c/metalab-lp-rebuild/main-5.jpg
https://motionprompts.dev/c/metalab-lp-rebuild/main-6.jpg
https://motionprompts.dev/c/metalab-lp-rebuild/main-7.jpg
https://motionprompts.dev/c/metalab-lp-rebuild/main-8.jpg
https://motionprompts.dev/c/metalab-lp-rebuild/main-9.jpg
https://motionprompts.dev/c/metalab-lp-rebuild/main-10.jpg
Ten backgrounds, ten thumbnails and the one default background — bg-i and main-i both belong to item i, so they always travel together.
They are hotlinkable for prototyping. For anything you ship, replace them: they are licensed for demonstration of this component, not for redistribution.
Using this outside its demo page
This component is written as a complete page — that is how the demo is meant to look. If you are dropping it into an existing project, or combining it with other components, these are the things it declares at document level and that you need to move or reconcile first.
- Palette on
:root—--sky-top,--sky-bottom,--sky,--ink,--ink-sky,--ink-dim,--lime,--card,--glass,--glass-edge. These names are not namespaced and they collide:--inkis defined by 164 of the 219 components in this catalogue,--paperby 94,--mutedby 80, each with different values — and they will also collide with whatever your own project defines. Move them onto the component's wrapper (.my-section { --ink: … }) or rename them with a prefix. - **Rules on
*,body** — the demo owns the whole document, so these set the page background, typography and resets. Dropped into an existing project they restyle the entire page, not just this section. Re-target them at the component's wrapper before using it. - Full-screen overlay — a fixed element covers the viewport (a loader or transition). Only one may exist per page and it must remove itself when done. If your page already has one, keep that and drop this; otherwise the second silently hides the first.
Adapting this to React
Everything above describes a standalone document: one script that runs once, reaches into the page with document.querySelector, and never has to undo itself. React withdraws all three of those guarantees at once, and it does it quietly — the component renders, looks right for a moment, and then misbehaves in a way that does not point back at any of this.
Under React 19 with StrictMode, every effect mounts, unmounts, and mounts again before anything reaches the screen — on the same DOM nodes, since StrictMode's double-invoke does not recreate the page in between. Setup that runs twice with teardown that runs never leaves you two of everything here too: two independent mouseenter/mouseleave pairs bound to the same .item rows, each closing over its own activePreview/isMouseOverItem, and a second, silently-appended batch of the ten .preview cards this component builds for itself. None of it reproduces in a production build, because React only double-invokes in development. Treat the cleanup as part of the effect, not as an afterthought.
*(1) The entry point* — The script waits for DOMContentLoaded. By the time a React component mounts, that event has already fired, so the listener is never called and the effect never runs — no error, no animation, nothing to debug. Delete the listener and move its entire body — the previews.forEach loop that builds the ten .preview cards *and* the items.forEach loop that wires their hover listeners — into a single useEffect with an empty dependency array. Keep both loops together: they share activePreview and isMouseOverItem, and splitting them across two effects splits that shared state too.
*(2) Element lookups* — Every unscoped document.querySelector above assumes the component owns the page: .container, .preview-bg, document.querySelectorAll(".item"), and the fallback document.querySelector(".preview.default") used both to seed activePreview and inside mouseleave. Give the component a root ref on .container and scope all of these to it. The per-card lookups nested inside applyVariantStyles and the hover handlers — previewElement.querySelector(...), newActivePreview.querySelector(...), activePreview.querySelector(".preview-img") — are already scoped to a specific card and need no change.
There is a sharper version of the same problem in how the cards get built. previews.forEach doesn't just read the DOM, it writes to it: for each of the ten entries it calls document.createElement, sets innerHTML, and appendChilds the result into .container — ten .preview-N nodes with no JSX counterpart. gsap.context().revert() (below) undoes the gsap.set() calls applyVariantStyles makes on those nodes' title/tags/description, but not the nodes themselves — appending a child is not something GSAP tracks. If the first StrictMode pass runs this loop before its cleanup fires, its ten cards are still sitting inside .container, ahead of the second pass's own ten in document order, when that second effect run appends a fresh set on top. From then on, every document.querySelector(.preview-${index + 1}) inside a hover handler resolves to the *first* match — the stale card from the already-reverted first pass, whose inline offsets have already snapped back to their at-rest values — never the correctly-hidden card the current mount just built. Hovering opens and closes a card that isn't wired to anything current, while the one this mount actually owns sits underneath it, permanently collapsed. Build the cards from previews.map() in JSX instead of the imperative loop and this failure mode disappears outright; if you keep the loop, clear out .container's generated children at the top of the effect before appending a new set.
*(3) Cleanup* — Wrap the card construction in a gsap.context scoped to the root ref:
useEffect(() => {
const ctx = gsap.context((self) => {
// previews.forEach(...): build the ten cards, call applyVariantStyles on each
}, rootRef);
return () => ctx.revert();
}, []);
That covers the setup pass, but none of the tweens this component spends most of its life running are created there. Every gsap.to inside mouseenter/mouseleave — the changeBg dissolve, the retract-and-reveal pair on the outgoing and incoming card, the text-block slide-in, the clip-path wipe — fires later, from a DOM event, well after the synchronous factory call has already returned. gsap.context only auto-tracks what runs during that synchronous call, so a context that merely wraps card construction tracks none of the hover animations, and ctx.revert() leaves every one of them untouched. Register the hover bodies as named context methods instead, and have the listeners call those:
const ctx = gsap.context((self) => {
// previews.forEach(...): build the ten cards, call applyVariantStyles on each
self.add("activate", (index) => {
// the full mouseenter body: changeBg, retract the outgoing card, reveal the
// incoming one, slide its text in, wipe its image open
});
self.add("deactivate", () => {
// the full mouseleave body: snap the text back, then the guarded revert-to-default
});
}, rootRef);
const listeners = Array.from(items).map((item, index) => {
const onEnter = () => ctx.activate(index);
const onLeave = () => ctx.deactivate();
item.addEventListener("mouseenter", onEnter);
item.addEventListener("mouseleave", onLeave);
return { item, onEnter, onLeave };
});
return () => {
listeners.forEach(({ item, onEnter, onLeave }) => {
item.removeEventListener("mouseenter", onEnter);
item.removeEventListener("mouseleave", onLeave);
});
ctx.revert();
};
Routed this way, a call through ctx.activate/ctx.deactivate is what makes the dissolve, the wipe and the text tweens tracked children of the context — including the manually appended <img> layers changeBg stacks inside .preview-bg. The incoming image's opacity tween reverts cleanly to the value changeBg set on it before the tween started, but the outgoing image is only ever removed from that same tween's own onComplete, and a revert kills the tween without firing it. Interrupt a dissolve mid-flight and the layer that was supposed to fade out and remove itself is left in .preview-bg at full opacity instead, sitting under every background this component stacks above it afterward.
The addEventListener calls are the other half of the doubling the intro paragraph names. Each effect run's items.forEach closes over its own activePreview/isMouseOverItem pair; if the listeners from a first StrictMode pass are never removed, the second pass's listeners fire alongside them on the same rows — two independent state machines, each convinced it owns the hover, each running its own changeBg (two new <img> layers racing into .preview-bg, each treating the other's freshly appended layer as the "old" one to fade out) and its own retract/reveal pair against the same card. The removeEventListener calls above are what prevent that, not ctx.revert() — a DOM listener is not a GSAP animation and the context has no idea it exists.
Last, deactivate's closing setTimeout(..., 10) is a plain timer that neither the context nor a removed listener touches. If the component unmounts inside that ten-millisecond window — a fast StrictMode churn is enough — the callback still runs, driving changeBg and the default-preview tweens through a context that has already been reverted. Keep the id setTimeout returns and clearTimeout it in the same cleanup.