Hover Image Reveal on List Items

An editorial index of six project rows; hovering a row unclips a floating image that follows the pointer with lag and tilts with its velocity, crossfades between rows, nudges the row text right and dims the others.

Loading

AI prompt

Recreate this effect with any AI tool

Paste into Claude, Cursor, v0, Lovable or Bolt. 1379 words of exact values — rewritten live as you tune the Playground.

prompt.md · Hover Image Reveal on List Items
### EFFECT: Hover Image Reveal on List Items
**What it does (visual):** A dark editorial index: a small heading ("Selected work", "06 projects · 2019 — 2026") above six hairline-divided rows, each showing a mono index (01–06), a large light serif project title, a mono category and a year on the right. Nothing else is visible. When the pointer enters a row, a 4:5 image frame (about 300px × 1 wide, slightly rounded, soft shadow) unclips from the bottom up directly under the cursor in 0.6s, showing that row's artwork. As the pointer moves, the frame follows with a soft lag (it closes 0.1 of the remaining distance each frame) and leans up to 14° in the direction of travel, straightening when the pointer stops. Sliding to the next row crossfades the artwork (0.4s) without re-clipping the frame. The hovered row's index and title slide 12px to the right while all other rows fade to 0.4 opacity. Leaving the list clips the frame away from the top down (0.45s), and the rows return. On touch devices and for reduced-motion users the floating frame does not exist: tapping a row expands its image inline beneath the row.

**Tech stack:**
- GSAP 3.13+ core (`gsap.ticker`, `clipPath` tweening, `gsap.matchMedia`).
- CDN: `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>`
- npm: `import gsap from "gsap";`
- Images: the demo paints each tile on a `<canvas>` (gradient + grain + caption) and exports a JPEG data URL; in production use real `<img src>`.

**HTML structure:**
```html
<section class="hil">
  <header class="hil__head"><p class="hil__label">Hover · Image reveal</p><h1 class="hil__title">Selected work</h1><p class="hil__meta">06 projects · 2019 — 2026</p></header>
  <ul class="hil__list">
    <li class="hil__item" data-hue="212">
      <button class="hil__row" type="button" aria-expanded="false">
        <span class="hil__index">01</span><span class="hil__name">Aurora Residence</span><span class="hil__cat">Architecture</span><span class="hil__year">2026</span>
      </button>
      <div class="hil__inline"><div class="hil__inline-media"><!-- <img> injected / your image --></div></div>
    </li>
    <!-- 5 more .hil__item -->
  </ul>
  <div class="hil__float" aria-hidden="true"><!-- one <img> per row, stacked --></div>
</section>
```

**CSS requirements:**
- `.hil`: `position: relative; min-height: 100vh; padding: 2.5rem 6vw 4rem;`
- `.hil__list { list-style: none; border-top: 1px solid var(--line) }`, `.hil__item { border-bottom: 1px solid var(--line) }`.
- `.hil__row`: `display: grid; grid-template-columns: 3rem 1fr auto 4rem; align-items: baseline; gap: 1.5rem; width: 100%; text-align: left; padding: 1.5rem 0; color: var(--text); cursor: pointer;` Focus: `outline: 2px solid var(--accent); outline-offset: -2px`.
- `.hil__index, .hil__cat, .hil__year`: mono 11px uppercase, `letter-spacing: .12em`, muted. `.hil__index, .hil__name { display: inline-block; will-change: transform }` (they are the parts that slide).
- `.hil__name`: `font-family: var(--font-display); font-weight: 300; font-size: clamp(1.5rem, 3.4vw, 3rem); letter-spacing: -0.03em; line-height: 1;`
- `.hil__float`: `position: fixed; left: 0; top: 0; z-index: 10; width: calc(clamp(220px, 24vw, 320px) * 1); aspect-ratio: 4 / 5; border-radius: 4px; overflow: hidden; pointer-events: none; clip-path: inset(100% 0% 0% 0%); will-change: transform, clip-path; box-shadow: 0 40px 80px -30px rgba(0,0,0,.7);` with `.hil__float img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; opacity: 0 }`.
- `.hil__inline { height: 0; overflow: hidden }`, `.hil__inline-media { padding: 0 0 1.5rem }`, `.hil__inline-media img { width: 100%; aspect-ratio: 3 / 2; object-fit: cover; border-radius: 4px }`.
- `@media (hover: none), (pointer: coarse)` and `@media (prefers-reduced-motion: reduce)`: `.hil__float { display: none }`.
- Under 768px: `.hil__row { grid-template-columns: 2.25rem 1fr 3rem; gap: .75rem 1rem; padding: 1.1rem 0 }` with the category wrapping to a second line (`grid-column: 2; grid-row: 2`).

**Animation spec (exact values):**
1. Constants: `LERP = 0.1`, `MAX_TILT = 14`, `REVEAL = 0.6`, `SHIFT = 12`, `DIM = 0.4`, `FRAME_SCALE = 1`.
2. Entrance: `gsap.from('.hil__head', { y: 24, opacity: 0, duration: 1, ease: 'power3.out', delay: 0.1 })`; `gsap.from('.hil__item', { y: 20, opacity: 0, duration: 0.9, ease: 'power3.out', stagger: 0.06, delay: 0.2 })` (tween the `<li>`, not the row, so later row tweens never overwrite it).
3. Setup: `gsap.set('.hil__float', { xPercent: -50, yPercent: -50 })`; state `pos = { x, y, tx, ty, rot }`.
4. Follow loop (`gsap.ticker.add(tick)`), every frame:
   `pos.x += (pos.tx − pos.x) × 0.1; pos.y += (pos.ty − pos.y) × 0.1; vx = pos.tx − pos.x; pos.rot += (clamp(−14, 14, vx × 0.08) − pos.rot) × 0.1; gsap.set(float, { x: pos.x, y: pos.y, rotation: pos.rot })`. `pointermove` on the list only updates `pos.tx/ty = clientX/clientY`.
5. `show(i)` on row `pointerenter` (also sets `tx/ty` to the event point first):
   - previous image: `gsap.to(img[prev], { opacity: 0, duration: 0.4, ease: 'power2.out', overwrite: true })`; new image: `gsap.set(img[i], { zIndex: 10 + i })` then `gsap.to(img[i], { opacity: 1, duration: 0.4, ease: 'power2.out', overwrite: true })`.
   - rows: active `gsap.to(row, { opacity: 1, duration: 0.4 })`, others `{ opacity: 0.4, duration: 0.4, ease: 'power2.out' }`; active row's `.hil__index, .hil__name` → `{ x: 12, duration: 0.5, ease: 'power3.out' }`, others `{ x: 0 }`.
   - first reveal only (`visible === false`): snap `pos.x = pos.tx; pos.y = pos.ty; pos.rot = 0`, run `tick()` once, then `gsap.fromTo(float, { clipPath: 'inset(100% 0% 0% 0%)' }, { clipPath: 'inset(0% 0% 0% 0%)', duration: 0.6, ease: 'expo.out', overwrite: true })`.
6. `hide()` on list `pointerleave`: `gsap.to(float, { clipPath: 'inset(0% 0% 100% 0%)', duration: 0.45, ease: 'expo.inOut', overwrite: true, onComplete: () => { gsap.set(float, { clipPath: 'inset(100% 0% 0% 0%)' }); gsap.set(img[prev], { opacity: 0 }) } })`; all rows back to `opacity: 1` (0.4s) and `x: 0` (0.5s `power3.out`).
7. Keyboard: `focus` on a row calls `show(i)` with `tx = rect.right − 220, ty = rect.top + rect.height / 2`; `focusout` leaving the list calls `hide()`.
8. Fallback branch (`(hover: none), (pointer: coarse), (prefers-reduced-motion: reduce)`): row `click` toggles `aria-expanded` and `gsap.to(inline, { height: open ? 'auto' : 0, duration: 0.5, ease: 'expo.out' })` (duration 0 under reduced motion), sliding the row text 12px while open; opening one row closes the previous.

**Interaction:** Hover a row → frame unclips under the cursor; move → frame lags and tilts with horizontal speed (max ±14°); cross rows → artwork crossfades; leave the list → frame clips away downward and the rows recover. Rows are `<button>`s so they are clickable/tabbable; Tab through them to see the same reveal anchored near the row's right edge. Touch: tap to expand inline, tap again (or another row) to collapse.

**Responsive behavior:** `gsap.matchMedia` splits the behaviour: the floating frame exists only for `(hover: hover) and (pointer: fine)` with motion allowed; everything else gets the inline toggle. The frame width is `clamp(220px, 24vw, 320px) × 1`; the title scales via `clamp(1.5rem, 3.4vw, 3rem)`; under 768px the row grid drops to three columns with the category on a second line, so the longest title fits at 375px.

**Accessibility:** `prefers-reduced-motion: reduce` disables the follow loop and the float entirely (CSS hides it, JS never creates the ticker) and uses the inline toggle with 0s durations. The float is `aria-hidden` and its images have empty `alt`; the inline images carry descriptive `alt` text. `aria-expanded` reflects the inline state. Focus rings are 2px lime inside the row. Dimmed rows stay at 0.4 opacity, which keeps the text legible (≥ 4.5:1 on the dark theme).

**Performance rules:** The frame is positioned with `transform` (`x`, `y`, `rotation`) via a single `gsap.set` per frame; `clip-path` and `opacity` are composited. `will-change` sits on `.hil__float` and the two sliding spans only. The ticker callback is removed on `ml:pause` / tab hidden and re-added on `ml:resume`. Six images are created once and reused for both float and inline. All of it lives in `gsap.context()`; `ctx.revert()` removes listeners (through the matchMedia cleanup) and restores inline styles on unmount.

**Customization knobs:**
- `LERP` (0.1) — lower = dreamier lag; 0.2 feels tight.
- `MAX_TILT` (14) and the `vx × 0.08` factor — how much the frame leans.
- `SHIFT` (12px) and `DIM` (0.4) — row emphasis.
- `FRAME_SCALE` (1) multiplies the frame size clamp; `aspect-ratio` (4/5) — portrait vs landscape thumbnails.
- Reveal timings: open 0.6s `expo.out`, close 0.45s `expo.inOut`, crossfade 0.4s.

**Acceptance criteria:**
- [ ] Nothing but the list is visible until a row is hovered; the frame appears under the cursor, clipped from the bottom up.
- [ ] Frame lags the pointer and tilts in the direction of motion, straightening at rest.
- [ ] Moving across rows crossfades images without re-running the clip reveal; hovered row text shifts 12px, others dim to 0.4.
- [ ] Leaving the list clips the frame away and restores all rows.
- [ ] Touch / reduced motion: tapping a row expands its image inline with `aria-expanded="true"`.
- [ ] No layout shift from the floating frame; no console errors.

Source

Full source code

<!-- thumb: hover=.hil__item:nth-child(3)>.hil__row wait=900 -->
<section class="hil">
  <header class="hil__head">
    <p class="hil__label">Hover · Image reveal</p>
    <h1 class="hil__title">Selected work</h1>
    <p class="hil__meta">06 projects · 2019 — 2026</p>
  </header>

  <ul class="hil__list">
    <li class="hil__item" data-hue="212">
      <button class="hil__row" type="button" aria-expanded="false">
        <span class="hil__index">01</span>
        <span class="hil__name">Aurora Residence</span>
        <span class="hil__cat">Architecture</span>
        <span class="hil__year">2026</span>
      </button>
      <div class="hil__inline"><div class="hil__inline-media"></div></div>
    </li>
    <li class="hil__item" data-hue="18">
      <button class="hil__row" type="button" aria-expanded="false">
        <span class="hil__index">02</span>
        <span class="hil__name">Ember Type Foundry</span>
        <span class="hil__cat">Identity</span>
        <span class="hil__year">2026</span>
      </button>
      <div class="hil__inline"><div class="hil__inline-media"></div></div>
    </li>
    <li class="hil__item" data-hue="150">
      <button class="hil__row" type="button" aria-expanded="false">
        <span class="hil__index">03</span>
        <span class="hil__name">Moss Botanical Atlas</span>
        <span class="hil__cat">Editorial</span>
        <span class="hil__year">2025</span>
      </button>
      <div class="hil__inline"><div class="hil__inline-media"></div></div>
    </li>
    <li class="hil__item" data-hue="275">
      <button class="hil__row" type="button" aria-expanded="false">
        <span class="hil__index">04</span>
        <span class="hil__name">Violet Hour Records</span>
        <span class="hil__cat">Campaign</span>
        <span class="hil__year">2024</span>
      </button>
      <div class="hil__inline"><div class="hil__inline-media"></div></div>
    </li>
    <li class="hil__item" data-hue="42">
      <button class="hil__row" type="button" aria-expanded="false">
        <span class="hil__index">05</span>
        <span class="hil__name">Saffron Table</span>
        <span class="hil__cat">Product</span>
        <span class="hil__year">2022</span>
      </button>
      <div class="hil__inline"><div class="hil__inline-media"></div></div>
    </li>
    <li class="hil__item" data-hue="195">
      <button class="hil__row" type="button" aria-expanded="false">
        <span class="hil__index">06</span>
        <span class="hil__name">Tide Observatory</span>
        <span class="hil__cat">Exhibition</span>
        <span class="hil__year">2019</span>
      </button>
      <div class="hil__inline"><div class="hil__inline-media"></div></div>
    </li>
  </ul>

  <div class="hil__float" aria-hidden="true"></div>
</section>