Work-List Hover — Pastel Panel Flip + Tilted Preview Reveal
Goal
Build a full-viewport, dark "just went live" work list: an intro line, three big project rows separated by hairlines, and a pill CTA. The star effect is a whole-panel hover flip: hovering any row (a) flips the entire #141414 panel to that row's pastel background and inverts all the text/CTA from white to dark navy, (b) slides a tilted diagonal strip of three half-transparent GIF preview cards (sitting behind the text) to a new top/left position so the card matching the hovered project glides center-stage and rotates a touch further, and (c) reveals a round arrow-pill on the hovered row where the arrow does a conveyor swap — the resting arrow exits right while a fresh arrow slides in from the left. Everything is CSS transition driven; JS only toggles a class and writes two inline overlay offsets.
Tech
Vanilla HTML/CSS/JS, shipped as index.html + styles.css + an ES-module script.js (<script type="module" src="./script.js">). No GSAP, no npm dependencies, no smooth scroll. All motion is pure CSS transition; JS only adds/removes classes and sets overlay.style.top / overlay.style.left. Arrow glyphs use the Phosphor Icons web font via CDN in <head> (<script src="https://unpkg.com/@phosphor-icons/web"></script>, then <i class="ph-bold ph-arrow-right">) — any right-arrow icon font or inline SVG is an acceptable substitute. Must run in a fresh Vite project with zero installs.
Layout / HTML
Class/ID names are load-bearing — the CSS and JS query them.
<div class="container">
<div class="work">
<div class="overlay">
<div class="prev" id="prev-1"><img src="prev-1.gif" alt="" /></div>
<div class="prev" id="prev-2"><img src="prev-2.gif" alt="" /></div>
<div class="prev" id="prev-3"><img src="prev-3.gif" alt="" /></div>
</div>
<p>Just went live with:</p>
<div class="work-item" id="w-1">
<div class="work-item-name"><h1>Aster</h1></div>
<div class="work-item-icon">
<div class="icon-holder i-1"><i class="ph-bold ph-arrow-right"></i></div>
<div class="icon-holder i-2"><i class="ph-bold ph-arrow-right"></i></div>
</div>
</div>
<div class="work-item" id="w-2">
<div class="work-item-name"><h1>Launching Nova Into The World</h1></div>
<div class="work-item-icon">
<div class="icon-holder i-1"><i class="ph-bold ph-arrow-right"></i></div>
<div class="icon-holder i-2"><i class="ph-bold ph-arrow-right"></i></div>
</div>
</div>
<div class="work-item" id="w-3">
<div class="work-item-name"><h1>Standby x Northwind</h1></div>
<div class="work-item-icon">
<div class="icon-holder i-1"><i class="ph-bold ph-arrow-right"></i></div>
<div class="icon-holder i-2"><i class="ph-bold ph-arrow-right"></i></div>
</div>
</div>
<div class="cta"><button>View all work</button></div>
</div>
</div>
Key points:
- Exactly three
.work-itemrows with ids#w-1,#w-2,#w-3. The project titles are arbitrary placeholder names — pick one short single word (row 1), one long title that wraps to two lines at the big type size (row 2), and one medium two-part title (row 3). No real brand or client names. - Each row holds a
.work-item-name > h1on the left and a.work-item-iconpill on the right, gapped apart. - Inside every
.work-item-iconthere are two.icon-holders (.i-1then.i-2), each containing one identical right-arrow glyph. The two-arrow duplication is what enables the conveyor swap. .overlayis the first child of.workand holds the three.prevGIF cards; it lives behind the text (z-index: 0).- The intro
<p>reads "Just went live with:"; the CTA is a single pill<button>reading "View all work".
Styling
- Global reset:
* { margin:0; padding:0; box-sizing:border-box; }.img { width:100%; height:100%; object-fit:cover; }. - Font: a clean neutral grotesque sans for everything (original uses a proprietary "Helvetica Now" variable face — Helvetica / Arial / Inter reads right).
.work:position:relative; width:100vw; height:100vh; background:#141414; padding:6em 4em; overflow:hidden; transition: background-color 0.5s ease;— this is the panel that flips color.overflow:hiddenclips the oversized tilted preview strip..overlay:position:absolute; top:0%; left:13.25%; width:100%; height:100%; pointer-events:none; z-index:0; display:flex; justify-content:center; align-items:center; gap:10em; opacity:0.5; transition:1s;— the previews sit half-transparent behind the text, and the whole strip tweens itstop/leftover 1s..prev:position:relative; width:500px; height:600px; background:gray; transform:rotate(10deg) scale(1); transition:transform 0.5s ease;— large portrait (~5:6) cards, all tilted 10°..prev.active:transform:rotate(12.5deg) scale(1);— the active card rotates a touch further (10°→12.5° over 0.5s).- Diagonal stagger via per-card
top:#prev-1 { top:-500px; },#prev-2 { top:0; },#prev-3 { top:500px; }— inside the centered flex row this makes a descending diagonal staircase of three cards. p(intro line):position:relative; color:#fff; font-size:30px; margin-bottom:1em; z-index:2; transition:0.5s;.h1(titles):color:#fff; opacity:0.5; font-size:80px; font-weight:500; letter-spacing:-0.025em; transition:0.5s;— dim white by default..work-item:position:relative; width:100%; padding:2em 0; display:flex; align-items:center; gap:2em; border-bottom:1px solid rgba(255,255,255,0.5); cursor:pointer; z-index:2; transition:0.5s;. The last row has no divider:.work-item#w-3 { border-bottom:none; }..work-item-icon:position:relative; width:70px; height:70px; border-radius:100%; background:#0b1925; color:#fff; overflow:hidden; left:-20px; opacity:0; transition:0.2s;— a dark-navy round pill, hidden by default and nudged 20px left.overflow:hiddenclips the sliding arrows..icon-holder:position:absolute; width:70px; height:70px; border-radius:100%; display:flex; justify-content:center; align-items:center; transition:0.5s;..i-1 { transition-delay:0.2s; left:-70px; }— parked one pill-width off to the left (out of the clipped pill)..i-2 { left:0px; }— the visible/centered arrow at rest.button:position:relative; background:#fff; color:#0b1925; border:none; padding:2em 4em; margin:2em 0; font-size:15px; border-radius:100px; font-weight:500; z-index:2; transition:0.5s;— a white pill, dark text.
Three pastel background classes (added to .work on hover — note the class *names* are arbitrary and do NOT match the actual hues; use the hex values):
.bg-color-red { background-color: rgb(164, 250, 249); } /* pale aqua / cyan */
.bg-color-blue { background-color: rgb(246, 250, 164); } /* pale yellow */
.bg-color-green { background-color: rgb(250, 164, 164); } /* pale pink/salmon */
Hovered-panel inversions (all gated behind a .hovered class on .work, so they tween together with the background over their 0.5s transitions):
.work.hovered button { background:#0b1925; color:#fff; } /* pill inverts */
.work.hovered h1 { color:#0b1925; opacity:1; } /* titles go dark & full-opacity */
.work.hovered p { color:#0b1925; }
.work.hovered .work-item { border-bottom:1px solid #0b1925; } /* dividers go dark */
.work.hovered .work-item#w-3{ border-bottom:none; }
Per-row arrow pill (native CSS :hover on the individual .work-item):
.work-item:hover .work-item-icon { opacity:1; left:0px; } /* pill fades in, slides right 20px */
.work-item:hover .icon-holder.i-1 { left:0; } /* fresh arrow slides in from left */
.work-item:hover .icon-holder.i-2 { left:70px; } /* resting arrow exits to the right */
The effect (exhaustive — this replaces the GSAP section)
There is no GSAP and no JS tweening loop. Two CSS transition engines plus a tiny vanilla-JS controller produce the whole thing. Durations/eases to reproduce exactly:
- Panel background:
background-color 0.5s easeon.work. - Text/CTA/divider inversions:
0.5s(default ease) onp,h1,.work-item,button— they retint the moment.hoveredis toggled. - Preview strip travel:
transition: 1son.overlay(drivestop/left). - Active card extra tilt:
transform 0.5s easeon.prev(10°→12.5°). - Arrow-pill reveal:
0.2son.work-item-icon(opacity 0→1, left −20px→0). - Arrow conveyor:
0.5son each.icon-holder, with.i-1delayed0.2sso the swap reads as sequential (out-going arrow leaves, then incoming arrow lands).
JS controller (runs inside DOMContentLoaded)
const workItems = document.querySelectorAll(".work-item");
const work = document.querySelector(".work");
const overlay = document.querySelector(".overlay");
const prevElements= document.querySelectorAll(".prev");
// Initial state:
overlay.style.top = "0%";
overlay.style.left = "13.25%";
document.querySelector("#prev-2").classList.add("active"); // middle card active at rest
function removeActiveClass() {
prevElements.forEach((prev) => prev.classList.remove("active"));
}
For each .work-item (with its index 0/1/2), attach two listeners:
mouseover →
removeActiveClass(), then addactiveto#prev-${index+1}(so the hovered row's matching card gains the 12.5° tilt).- Add
hoveredto.work, then set the panel's full class string and the overlay offsets per index (the class assignmentwork.className = "work bg-color-… hovered"both applies the pastel bg and keepshovered): - index 0 (row 1):
overlay.style.top = "50%"; overlay.style.left = "50%";andwork.className = "work bg-color-red hovered";→ pale aqua panel; the diagonal strip slides down-right so prev-1 lands center. - index 1 (row 2):
overlay.style.top = "0%"; overlay.style.left = "13.25%";andwork.className = "work bg-color-blue hovered";→ pale yellow panel; overlay stays at its rest position with prev-2 center. - index 2 (row 3):
overlay.style.top = "-50%"; overlay.style.left = "-23.5%";andwork.className = "work bg-color-green hovered";→ pale pink panel; the strip slides up-left so prev-3 lands center.
mouseout → reset everything: remove hovered, work.className = "work" (back to #141414), overlay.style.top = "0%"; overlay.style.left = "13.25%";, removeActiveClass(), and re-add active to #prev-2.
Because the overlay is a 100%×100% absolutely-positioned box holding a diagonal strip of three oversized cards, shifting its top/left by these percentages glides a *different* card behind the centered text column, and the 1s transition makes that a smooth slide. The pastel flip (0.5s) and the arrow pill (native :hover) fire simultaneously since the cursor triggers both the JS mouseover and the CSS :hover at once.
No ScrollTrigger, no timelines, no stagger arrays, no rAF loop, no scroll behavior — the only triggers are per-row mouseover / mouseout (JS) and per-row :hover (CSS), plus the one on-load initial state.
Assets / images
- 3 looping GIF preview cards, portrait ~5:6 (rendered at 500×600), shown
object-fit: coveratopacity: 0.5behind the text. Each should be dark, cinematic, editorial motion footage — moody, low-key lit, low-contrast — so text stays legible over them. One per project row; the middle one (prev-2) is the default/at-rest card. Suggested distinct subjects: an overhead aerial of figures on a marked ground; a moody dusk tracking shot along a long wall; a low-key interior looking outward past objects toward a colored glow. No brands, logos, or recognizable client work. Static JPGs work as a fallback if GIFs aren't available.
Behavior notes
- Desktop-only effect (large 500×600 previews). Responsive at
max-width: 900px:.work { padding:4em 1em; },.work-item { gap:1em; },p { font-size:16px; },h1 { font-size:30px; },.work-item-icon { transform:scale(0.75); },button { padding:1em 2em; }. - No reduced-motion handling in the original; transitions are short (≤1s) and gentle.
- Only one row is ever "hovered" at a time; leaving a row instantly returns the panel to dark, prev-2 active, overlay at rest.
- Dark UI at rest:
#141414ground, white 30px intro line, 80px dim-white titles (opacity 0.5), 50%-white hairline dividers, white CTA pill with#0b1925text.