모션·인터랙션 / Cadence
Hover Preview List
호버 · 커서 · 갤러리 모션. 미리보기와 원본 코드·프롬프트를 이곳에서 확인하세요.
미리보기 안에서 스크롤하거나 마우스를 움직여 모션을 확인하세요.
HTML · CSS · JavaScript 전체 코드
<!doctype html>
<!--
Hover Preview List
A list of project titles. Hovering a title makes its photo appear near the cursor and
follow it with a soft lag. Moving to another title swaps the photo: the new one scales
in over the old one. The other titles dim and the hovered one shifts right a little.
On touch, tapping a row shows its photo next to it.
Dependencies: GSAP 3.15 (jsDelivr).
Tweak (CONFIG in the script):
- lag: 0.5s for the photo to catch up with the cursor (quickTo, ease "power3")
- offsetX / offsetY: the photo's top-left corner sits 40px right of and 120px above the cursor
- swapDuration: 0.45s, the new photo scales 0.8 → 1 and fades in (ease "power3.out")
- Photo fade in / out: 0.3s
- CSS: row indent 12px (320ms, cubic-bezier(0.22, 1, 0.36, 1)), dimmed rows #8e8e8e,
photo width clamp(180px, 24vw, 320px) at 4:5
From The Animations Index, "Hover Preview List" (own project).
-->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Hover Preview List</title>
<style>
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
background: #fff;
color: #000;
font-family: system-ui, -apple-system, "Segoe UI", Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
}
/* The list, vertically centred in the viewport */
.stage {
position: relative;
display: flex;
flex-direction: column;
justify-content: center;
min-height: 100vh;
min-height: 100svh;
padding: 32px clamp(16px, 2.5vw, 32px);
overflow: hidden;
}
.rows {
display: flex;
flex-direction: column;
max-width: 60ch;
margin: 0;
padding: 0;
list-style: none;
}
.row {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 32px;
padding: 16px 0;
border-top: 1px solid #000;
cursor: pointer;
transition: color 320ms, padding-left 320ms cubic-bezier(0.22, 1, 0.36, 1);
}
.row:last-child {
border-bottom: 1px solid #000;
}
.row__title {
font-size: clamp(1.5rem, 3vw, 2.5rem);
font-weight: 400;
letter-spacing: -0.02em;
line-height: 1.5;
}
.row__year {
font-size: 12px;
font-weight: 500;
letter-spacing: 0.25em;
text-transform: uppercase;
color: #8e8e8e;
}
/* The other rows dim while one is hovered */
.rows:hover .row:not(:hover) {
color: #8e8e8e;
}
.row:hover,
.row.is-active {
padding-left: 12px;
}
/* The floating preview */
.preview {
position: fixed;
top: 0;
left: 0;
width: clamp(180px, 24vw, 320px);
aspect-ratio: 4 / 5;
pointer-events: none;
opacity: 0;
z-index: 5;
will-change: transform;
}
.preview__image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 6px;
}
/* Reduced motion: the preview sits to the right of the list. */
.stage.is-static .preview {
position: absolute;
top: 50%;
right: clamp(16px, 2.5vw, 32px);
left: auto;
transform: translateY(-50%);
opacity: 1;
}
</style>
</head>
<body>
<main class="stage" id="stage">
<!-- Rows are built by the script from CONFIG.projects -->
<ol class="rows" id="rows" aria-label="Projects"></ol>
<!-- The floating preview. Two images so one can swap in over the other. -->
<div class="preview" id="preview" aria-hidden="true">
<img class="preview__image" id="preview-a" alt="">
<img class="preview__image" id="preview-b" alt="">
</div>
</main>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
<script>
const CONFIG = {
// [title, year, photo]. Swap the picsum.photos placeholders for your own portrait photos.
projects: [
["Olive Grove", "2026", "https://picsum.photos/seed/cadence-1/600/800"],
["Linen", "2025", "https://picsum.photos/seed/cadence-2/600/800"],
["Pier in Winter", "2025", "https://picsum.photos/seed/cadence-3/600/800"],
["Lighthouse", "2024", "https://picsum.photos/seed/cadence-4/600/800"],
["Stairwell", "2024", "https://picsum.photos/seed/cadence-5/600/800"],
["Lavender House", "2023", "https://picsum.photos/seed/cadence-6/600/800"],
],
lag: 0.5, // seconds the preview needs to catch up
offsetX: 40, // px from the pointer
offsetY: -120,
swapDuration: 0.45,
};
/* Build */
const stage = document.getElementById("stage");
const rows = document.getElementById("rows");
const preview = document.getElementById("preview");
const layers = [document.getElementById("preview-a"), document.getElementById("preview-b")];
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const touch = window.matchMedia("(hover: none), (pointer: coarse)").matches;
const images = CONFIG.projects.map((project) => project[2]);
const rowElements = CONFIG.projects.map(([title, year]) => {
const row = document.createElement("li");
row.className = "row";
row.innerHTML = `<span class="row__title">${title}</span><span class="row__year">${year}</span>`;
rows.appendChild(row);
return row;
});
// Preload so swaps are instant.
images.forEach((src) => {
const img = new Image();
img.src = src;
});
/* Swapping photos: the new one scales in over the old one */
let current = -1;
let front = 0; // index into `layers` of the image currently on top
function show(index) {
if (index === current) return;
current = index;
rowElements.forEach((row, i) => row.classList.toggle("is-active", i === index));
const back = 1 - front;
layers[back].src = images[index];
gsap.set(layers[back], { zIndex: 2, scale: 0.8, opacity: 0 });
gsap.set(layers[front], { zIndex: 1 });
gsap.to(layers[back], { scale: 1, opacity: 1, duration: reduced ? 0 : CONFIG.swapDuration, ease: "power3.out" });
front = back;
}
/* Following the pointer */
if (reduced) {
stage.classList.add("is-static");
show(0);
rowElements.forEach((row, i) => row.addEventListener("pointerenter", () => show(i)));
} else {
const moveX = gsap.quickTo(preview, "x", { duration: CONFIG.lag, ease: "power3" });
const moveY = gsap.quickTo(preview, "y", { duration: CONFIG.lag, ease: "power3" });
const fade = gsap.quickTo(preview, "opacity", { duration: 0.3 });
if (!touch) {
window.addEventListener("pointermove", (event) => {
moveX(event.clientX + CONFIG.offsetX);
moveY(event.clientY + CONFIG.offsetY);
});
rowElements.forEach((row, i) => {
row.addEventListener("pointerenter", () => {
show(i);
fade(1);
});
});
rows.addEventListener("pointerleave", () => fade(0));
} else {
// Touch: tap a row, the photo appears to its right.
rowElements.forEach((row, i) => {
row.addEventListener("click", () => {
const rect = row.getBoundingClientRect();
gsap.set(preview, { x: Math.min(rect.right - 40, window.innerWidth - preview.offsetWidth - 16), y: rect.top - preview.offsetHeight / 2 });
show(i);
fade(1);
});
});
}
}
</script>
<script type="module" src="https://static.cloudflareinsights.com/beacon.min.js/vce701823e78c4301a35b7e07294d87301792088814092" integrity="sha512-UKNk8GbxkABxKcXYuLkO16EleLGUkcvJWtpSNmExE0Too3ZaFAxTscNWEc1OdAv5/BikHawapSCaiD2XCmp+Bw==" data-cf-beacon='{"version":"2024.11.0","token":"c54fa41b83884ade97f239ecb41aee46","r":1,"spa":2}' crossorigin="anonymous"></script>
</body>
</html>
원본 구현 프롬프트
# Hover Preview List: a project list whose photo follows the cursor
**Goal:** build a list of project titles where hovering a title shows its photo near the cursor, trailing it with a soft lag, and moving to another title swaps the photo with a quick scale-in.
## Visual behaviour
1. **The list.** A vertical list of rows, vertically centred in the viewport, left-aligned, max width about `60ch`. Each row has a large title on the left and a small year on the right (baseline-aligned, 32px gap), 16px vertical padding and a 1px black line on top (the last row also gets one at the bottom).
2. **Hover states (CSS).**
- While the pointer is over the list, every row except the hovered one turns grey (`#8e8e8e`).
- The hovered row (and the row whose photo is showing, via an `is-active` class) shifts right with `padding-left: 12px`.
- Both transition over **320ms**: colour with the default ease, the indent with `cubic-bezier(0.22, 1, 0.36, 1)`.
3. **The floating photo.** One fixed-position element (top-left at 0,0, moved with transforms), `pointer-events: none`, 4:5, width `clamp(180px, 24vw, 320px)`, 6px rounded corners, hidden (`opacity: 0`) at first.
- On every pointer move anywhere on the page, its target position is the cursor **+40px on x** and **−120px on y** (so its top-left sits up and to the right of the cursor).
- It eases toward that target on each axis separately over **0.5s** with `power3` (ease-out cubic, ≈ `cubic-bezier(0.215, 0.61, 0.355, 1)`), re-targeting on every move, so it trails the cursor softly.
4. **Showing.** When the pointer enters a row, that row's photo is shown and the photo element fades to `opacity: 1` over **0.3s**. When the pointer leaves the whole list, it fades to `0` over 0.3s (it keeps following the cursor while invisible).
5. **Swapping.** The photo element holds two stacked `<img>` layers. On entering a different row:
- The back layer gets the new photo, goes on top (`z-index: 2`, the old one `1`) and animates from `scale: 0.8, opacity: 0` to `scale: 1, opacity: 1` over **0.45s**, `power3.out`.
- The layers then swap roles for the next change. Entering the row that is already showing does nothing.
6. **Touch** (`(hover: none), (pointer: coarse)`): no following. Tapping a row places the photo to the right of the row (its left edge at the row's right edge − 40px, kept 16px inside the viewport), vertically centred on the row, then shows it as above.
## Exact values
| What | Value |
|---|---|
| Follow | 0.5s per axis, `power3` (ease-out), re-targeted on every pointer move |
| Offset from cursor | +40px x, −120px y |
| Fade in / out | 0.3s |
| Swap | scale 0.8 → 1, opacity 0 → 1, 0.45s `power3.out` |
| Row indent | 12px, 320ms `cubic-bezier(0.22, 1, 0.36, 1)` |
| Dimmed rows | `#8e8e8e`, 320ms |
| Photo | 4:5, `clamp(180px, 24vw, 320px)` wide, 6px radius, `object-fit: cover` |
| Title | `clamp(1.5rem, 3vw, 2.5rem)`, weight 400, `letter-spacing: -0.02em`, line-height 1.5 |
| Year | 12px, weight 500, uppercase, `letter-spacing: 0.25em`, grey |
## Markup
```html
<main class="stage">
<ol class="rows">
<li class="row"><span class="row__title">Olive Grove</span><span class="row__year">2026</span></li>
…
</ol>
<div class="preview" aria-hidden="true">
<img class="preview__image" alt="">
<img class="preview__image" alt="">
</div>
</main>
```
- Keep the data (title, year, photo URL) in one array and build the rows from it.
- Preload every photo at start so swaps are instant.
- Both images: `position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover`.
## Libraries
- **GSAP 3**: `gsap.quickTo(preview, "x", { duration: 0.5, ease: "power3" })` and the same for `"y"`; a third `quickTo` for `opacity` (0.3s); `gsap.set` + `gsap.to` for the swap.
- **Plain alternative:** lerp the photo's position toward the target in a `requestAnimationFrame` loop (a factor around 0.1–0.12 per frame at 60fps feels similar), and use CSS transitions or the Web Animations API for the fade and the swap.
## Responsive and reduced motion
- The photo width scales with the viewport (24vw, clamped 180–320px).
- On touch devices, tap a row to show its photo beside it (no following).
- `prefers-reduced-motion: reduce`: the photo doesn't follow the pointer. It sits fixed to the right of the list, vertically centred, always visible, showing the first project, and hovering a row swaps it instantly (no scale, no fade).
## Acceptance criteria
- Hovering a title fades its photo in up and to the right of the cursor, and the photo trails the cursor with a soft, lagging ease.
- Moving down the list swaps the photo each time a new row is entered, with the new photo scaling up from 80% over the old one.
- The hovered row indents 12px and the others turn grey; leaving the list fades the photo out and restores the colours.
- No flicker or stacking when moving quickly across many rows.
- Works by tap on touch screens; with reduced motion the photo is static beside the list.
보관·활용 메모
Cadence에서 제공하는 코드와 프롬프트, 미리보기 영상을 이 사이트에 보관했습니다. 코드는 다운로드한 원본으로 제공하며 실행 화면은 별도로 격리되어 있습니다. 코드의 외부 라이브러리·샘플 이미지 요청은 유지됩니다.
보관일: 2026-10-26 · 프로젝트 적용: 별도 검증 필요
원본 코드·프롬프트·제작자 정보를 함께 보관했습니다. 실제 납품 시 코드와 포함 소재의 이용 조건을 확인하세요.
코드 SHA-256: 9cf455c7a5ace8742160bdd746e172d8c0ea99a511502c688a2f7f5e9c505d4a