Spotlight List Hover Cards — Handwritten Label Swap, Proximity Row Growth & Elastic Glass Cards
Goal
Build a three-section page whose middle section is a dark, rounded panel with a centred list of six titles. When the pointer enters the list, four glass-framed portrait cards parked in the panel's corners pop from scale 0 with an elastic ease. When the pointer hovers a title: the bold uppercase title squashes, blurs and fades out while a handwritten version scales in (back-out ease); the hovered row grows by 50px and every other row shrinks in proportion to its distance (weights 1/|i−j|), so the list breathes around the cursor; and the four cards load that title's images and reshuffle to new random offsets/rotations while following the hovered row's vertical position. Leaving the list scales the cards away and restores the rows. A soft light inside the panel follows the pointer. GSAP with CustomEase.
Tech
Vanilla HTML/CSS/JS with ES module imports. gsap (npm) + CustomEase. Fonts: Anton (titles), Caveat 600 (handwritten), DM Sans 500 (body). No smooth-scroll library.
Layout / HTML
<div class="sl">
<section class="sl-intro"><h1>The Sketchbook</h1></section>
<section class="sl-spotlight">
<div class="sl-panel">
<p class="sl-panel__eyebrow">Explore the collection</p>
<div class="sl-stage">
<div class="sl-cards" aria-hidden="true">
<img class="sl-card sl-card--1" data-rotation="-10" alt="" />
<img class="sl-card sl-card--2" data-rotation="10" alt="" />
<img class="sl-card sl-card--3" data-rotation="8" alt="" />
<img class="sl-card sl-card--4" data-rotation="-8" alt="" />
</div>
<div class="sl-list">
<!-- ×6 -->
<a class="sl-item" href="#">
<span class="sl-label">
<span class="sl-label__default">Drawn and dressed</span>
<span class="sl-label__alt">Drawn and dressed</span>
</span>
</a>
…Sketch the look · Palette of style · Muse in ink · Runway reimagined · Bold brushstroke…
</div>
</div>
</div>
</section>
<section class="sl-outro"><h1>Stay iconic</h1></section>
</div>
Styling
:root: --sl-paper:#f2f0eb, --sl-ink:#151618, --sl-panel:#101114, --sl-panel-ink:#f4f2ee, --sl-glow:#6f7cff, --sl-line:rgba(255,255,255,.22), --sl-card-w:clamp(90px,15vw,210px).
.sl—background:var(--sl-paper); color:var(--sl-ink); font-family:"DM Sans".h1— Anton,clamp(2.6rem,8vw,11rem), uppercase,line-height:1. Sections:position:relative; min-height:100svh; padding:1.5rem; overflow:hidden; intro/outro centre theirh1..sl-panel—position:relative; min-height:calc(100svh - 3rem); padding:8rem 2rem; border-radius:2.5rem; background:var(--sl-panel); color:var(--sl-panel-ink); display:flex; flex-direction:column; justify-content:center; align-items:center; text-align:center; gap:2rem; overflow:hidden; isolation:isolate. Its::beforeis the pointer light:radial-gradient(38rem 38rem at var(--sl-mx,50%) var(--sl-my,50%), color-mix(in srgb, var(--sl-glow) 22%, transparent), transparent 70%),opacity:0→1with.is-lit(0.8s)..sl-panel__eyebrow—.8rem, weight 500,letter-spacing:.14em, uppercase,opacity:.6..sl-stage—position:relative; width:100%; display:flex; justify-content:center; align-items:center; z-index:1..sl-list— column, centred,z-index:1..sl-item—width:100%; display:flex; justify-content:center; align-items:center(JS sets its height)..sl-label—display:grid; place-items:center; both spans sharegrid-area:1/1; white-space:nowrap..sl-label__default— Antonclamp(1.6rem,6vw,8rem), uppercase,line-height:1..sl-label__alt— Caveat 600clamp(2.2rem,8vw,10.5rem),line-height:.7; letter-spacing:-.03em; transform:translateY(12%); opacity:0..sl-cards—position:absolute; inset:0; z-index:2; pointer-events:none..sl-card—position:absolute; width:var(--sl-card-w); aspect-ratio:2/3; object-fit:cover; border-radius:1rem; border:1px solid var(--sl-line); padding:5px; background:rgba(255,255,255,.06); box-shadow:0 30px 60px -28px rgba(0,0,0,.8), inset 0 1px 0 rgba(255,255,255,.3); transform:scale(0). Corners: card-1top:7.5%; left:7.5%, card-2top:5%; right:7.5%, card-3bottom:12.5%; left:12.5%, card-4bottom:15%; right:12.5%.@media (max-width:1000px): section padding 1rem, panelpadding:6rem 1rem; border-radius:1.5rem.
GSAP effect (be exact)
gsap.registerPlugin(CustomEase); CustomEase.create("sl-hop", "0.35, 0.75, 0, 1"). Build in mount(config) → destroy().
const DEFAULTS = { hoverGrowth: 50, cardXRange: 100, cardYJitter: 50, cardRotationJitter: 20, cardFollow: .75, cardsDuration: 1, labelDuration: .25, entryBlur: 6 };
const HEADROOM = 10; // px added to the tallest row
Row height baseline — measure(): clear inline heights, baseHeight = round(max(item.offsetHeight) + HEADROOM), gsap.set(items, { height: baseHeight }). Run at mount, on resize, and again after document.fonts.ready.
Card init — each card stores baseRotation = parseFloat(dataset.rotation), loads item_1_card_{n}.jpg, and is set to { rotation: baseRotation, scale: 0, filter: "blur(6px)" }.
Label swap — animateLabel(el, { opacity, scaleX, scaleY, blur }) fires three overlapping tweens with overwrite:"auto": opacity (duration labelDuration*.4, delay:.025, ease sl-hop), scaleX/scaleY (labelDuration, ease:"back.out(1.75)"), filter: blur(px) (labelDuration, sl-hop). On enter: default → {0, .75, 1.25, 1} and alt → {1, 1, 1, 0}; on leave, the inverse.
Cards follow the row — placeCards(item, n): followY = (itemCenterY − listCenterY) * cardFollow (from getBoundingClientRect); each card gets src = item_${n}_card_${i}.jpg and tweens { x: random(−cardXRange, cardXRange), y: followY + random(−cardYJitter, cardYJitter), rotation: baseRotation + random(−cardRotationJitter, cardRotationJitter), duration: cardsDuration, ease: "elastic.out(1, 0.5)", overwrite: "auto" }.
List enter/leave — mouseenter on .sl-list: cards { scale: 1, filter: "blur(0px)", duration: .75, ease: "elastic.out(1, 0.6)", overwrite: "scale,filter" }. mouseleave: cards { scale: 0, filter: "blur(6px)", duration: .5, ease: "power3.out" } and all rows { height: baseHeight, duration: .35, ease: "power3.out" }.
Row enter (index idx) — swap labels, placeCards(item, idx + 1), then weights[i] = i === idx ? 0 : 1 / |i − idx|, total = Σ weights; row i height → idx ? baseHeight + hoverGrowth : round(baseHeight − hoverGrowth * weights[i] / total), duration: .5, ease: "power2.out", overwrite: "auto". Row leave — swap labels back (heights are restored only when leaving the list).
Panel light — pointermove on the panel writes --sl-mx/--sl-my as percentages; pointerenter/leave toggle .is-lit.
destroy() — remove listeners, kill tweens, clearProps:"all" on cards, rows and label spans, remove .is-lit and the two variables.
Assets / images
Twenty-four portrait photos (2:3 crop; ~600px wide is fine), four per list item, each set with a consistent mood (studio fashion, monochrome portraits, fabric in motion, soft editorial, street style, colour studio). File names item_{1–6}_card_{1–4}.jpg.
Behavior notes
- Hover-only by design; on touch devices the list is a plain list and the cards never appear.
- Cards are
<img>with padding and a translucent background, which is what draws the glass frame around the photo. - Row heights are animated in px, so the list never reflows the page: total height is constant (growth on one row is paid for by the others).
- No
:rootrules beyond the prefixed variables; the panel is self-contained and can be dropped between any two sections.
Images
This component ships with 24 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/spotlight-list-hover/item_1_card_1.jpg
https://motionprompts.dev/c/spotlight-list-hover/item_1_card_2.jpg
https://motionprompts.dev/c/spotlight-list-hover/item_1_card_3.jpg
https://motionprompts.dev/c/spotlight-list-hover/item_1_card_4.jpg
https://motionprompts.dev/c/spotlight-list-hover/item_2_card_1.jpg
https://motionprompts.dev/c/spotlight-list-hover/item_2_card_2.jpg
… 18 more under https://motionprompts.dev/c/spotlight-list-hover/
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—--sl-paper,--sl-ink,--sl-panel,--sl-panel-ink,--sl-glow,--sl-line,--sl-card-w. 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
html, 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.