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.itemblocks, 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 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 palso hasfont-weight:lighter; border:1px solid rgba(255,255,255,0.1); transition:0.3s. On.item:hover p→background: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-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-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 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, 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-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.