All components

Spotlight List Hover Cards

GSAP animation component · Published 2026-09-05 · by vanguardia.dev

Open live demo ↗ Raw prompt (.md)

What it does

Hover list inside a dark rounded panel: entering the list pops four glass-framed cards from scale 0 with an elastic ease; hovering a title swaps it for a handwritten version (opacity, back-out scale and blur), grows its row while the neighbours shrink in proportion to their distance, and reshuffles the cards with random offsets and rotation as they follow the row's height. A soft light in the panel follows the pointer.

How it's built

Categoryhover
Techgsap
GSAP pluginsCustomEase
Complexitysection
Performance costmedium
Mobile-safedesktop-first

hover list cards elastic blur handwritten glass spotlight

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

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).

  • .slbackground: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 their h1.
  • .sl-panelposition: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 ::before is 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:01 with .is-lit (0.8s).
  • .sl-panel__eyebrow.8rem, weight 500, letter-spacing:.14em, uppercase, opacity:.6.
  • .sl-stageposition:relative; width:100%; display:flex; justify-content:center; align-items:center; z-index:1. .sl-list — column, centred, z-index:1. .sl-itemwidth:100%; display:flex; justify-content:center; align-items:center (JS sets its height).
  • .sl-labeldisplay:grid; place-items:center; both spans share grid-area:1/1; white-space:nowrap. .sl-label__default — Anton clamp(1.6rem,6vw,8rem), uppercase, line-height:1. .sl-label__alt — Caveat 600 clamp(2.2rem,8vw,10.5rem), line-height:.7; letter-spacing:-.03em; transform:translateY(12%); opacity:0.
  • .sl-cardsposition:absolute; inset:0; z-index:2; pointer-events:none. .sl-cardposition: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-1 top:7.5%; left:7.5%, card-2 top:5%; right:7.5%, card-3 bottom:12.5%; left:12.5%, card-4 bottom:15%; right:12.5%.
  • @media (max-width:1000px): section padding 1rem, panel padding: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 baselinemeasure(): 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 swapanimateLabel(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 rowplaceCards(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/leavemouseenter 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 lightpointermove 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 :root rules 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: --ink is defined by 164 of the 219 components in this catalogue, --paper by 94, --muted by 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.