모션·인터랙션 / Cadence
Opposite Scroll
스크롤 · 갤러리 · 텍스트 모션. 미리보기와 원본 코드·프롬프트를 이곳에서 확인하세요.
미리보기 안에서 스크롤하거나 마우스를 움직여 모션을 확인하세요.
HTML · CSS · JavaScript 전체 코드
<!doctype html>
<!--
Opposite Scroll
Two columns of portrait photos travel in opposite directions as you scroll and pass each
other around a still wordmark in the centre: the left column moves down, the right column
moves up. The page itself never scrolls. The wheel, trackpad, touch drags and arrow / Page
keys move one endless scroll position, and the columns ease toward it (frame-rate
independent exponential smoothing). Each column holds its photo set repeated and wraps by
exactly one set, so the loop has no seam in either direction. The columns are rebuilt on
resize. With prefers-reduced-motion the columns follow the scroll without easing.
Dependencies: none (plain HTML, CSS and JS). Poppins from Google Fonts (optional).
Tweak (CONFIG in the script):
- perColumn 5 photos per column set (the set repeats to fill the loop)
- speed 1 px a column moves per px scrolled
- smoothing 0.16 s, time constant of the ease: current += (target - current) × (1 - e^(-dt/0.16))
- touchSpeed 1.6 touch drags move the columns further than the finger travels
- keys ArrowDown / ArrowUp 120px, PageDown / PageUp / Space 0.8 × viewport height
Tweak (in the CSS): column gap, photo gap and the wordmark sizes on .duet-columns,
.duet-column and .duet-wordmark.
From The Animations Index, 57 Opposite Scroll (own project).
-->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Opposite Scroll</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500&display=swap">
<style>
*, *::before, *::after { box-sizing: border-box; }
/* The page never scrolls: the script owns the wheel, touch and keys. */
html, body {
height: 100%;
overflow: hidden;
overscroll-behavior: none;
}
body {
margin: 0;
background: #fff;
color: #000;
font-family: "Poppins", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
font-weight: 400;
-webkit-font-smoothing: antialiased;
}
.duet {
height: 100vh;
height: 100svh;
overflow: hidden;
touch-action: none; /* touch drags scroll the columns, not the page */
user-select: none;
-webkit-user-select: none;
}
.duet-columns {
height: 100%;
display: grid;
grid-template-columns: 1fr auto 1fr;
grid-template-rows: 100%; /* one viewport-tall row: the columns overflow it, the wordmark centres in it */
gap: clamp(12px, 3.4vw, 48px);
padding: 0 clamp(16px, 2.5vw, 32px);
align-items: center;
}
.duet-column {
display: flex;
flex-direction: column;
gap: clamp(12px, 2.2vw, 32px);
align-self: start;
will-change: transform;
}
.duet-column img {
display: block;
width: 100%;
max-width: 100%;
height: auto;
aspect-ratio: 3 / 4;
object-fit: cover;
border-radius: 6px;
background: #efefef;
}
/* The wordmark holds still between the columns. Its slot is wider than the word, so the
photo columns keep their size and the word gets air on both sides. */
.duet-wordmark {
--wordmark-slot: calc(clamp(36px, 8.4vw, 132px) * 4.34);
width: var(--wordmark-slot);
margin: 0;
font-size: clamp(20px, 4.4vw, 70px);
font-weight: 500;
letter-spacing: -0.04em;
line-height: 1;
white-space: nowrap;
text-align: center;
user-select: none;
}
</style>
</head>
<body>
<!-- The columns are filled by the script (see LEFT / RIGHT below). -->
<section class="duet" data-duet aria-label="Opposite scroll">
<div class="duet-columns">
<div class="duet-column" data-column="down"></div>
<p class="duet-wordmark">Studio</p>
<div class="duet-column" data-column="up"></div>
</div>
</section>
<script>
(function () {
"use strict";
/* CONFIG ----------------------------------------------------------------- */
const CONFIG = {
perColumn: 5,
speed: 1, // px a column moves per px scrolled
smoothing: 0.16, // s, time constant of the ease toward the scroll position
touchSpeed: 1.6, // touch drags move further than they travel, like a flick of the page
};
/* Photos ----------------------------------------------------------------- */
// Media: swap these picsum.photos placeholders for your own portrait (3:4) photos,
// about 900 × 1200. Use perColumn photos per column; a single shoot per column keeps
// each column feeling like one story.
const LEFT = [1, 2, 3, 4, 5].map((n) => `https://picsum.photos/seed/cadence-${n}/900/1200`); // moves down
const RIGHT = [6, 7, 8, 9, 10].map((n) => `https://picsum.photos/seed/cadence-${n}/900/1200`); // moves up
/* Build ------------------------------------------------------------------ */
const section = document.querySelector("[data-duet]");
const left = document.querySelector('[data-column="down"]');
const right = document.querySelector('[data-column="up"]');
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
function setHtml(photos) {
return photos
.slice(0, CONFIG.perColumn)
.map((src) => `<img src="${src}" alt="" width="600" height="800" decoding="async" draggable="false">`)
.join("");
}
// A column holds its set repeated until one set plus a screen fits, so wrapping by one
// set never shows an edge.
let setHeight = 1;
function fill(column, photos) {
const html = setHtml(photos);
column.innerHTML = html + html;
// One set's height: from the first photo to the first photo of the second copy (gaps included).
setHeight = column.children[CONFIG.perColumn].offsetTop - column.children[0].offsetTop;
while (column.offsetHeight < setHeight + window.innerHeight * 1.5) column.insertAdjacentHTML("beforeend", html);
}
function build() {
fill(left, LEFT);
fill(right, RIGHT);
}
build();
/* Scroll: one endless position, eased toward ----------------------------- */
let target = 0;
let current = 0;
window.addEventListener(
"wheel",
(event) => {
event.preventDefault();
const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? window.innerHeight : 1;
target += event.deltaY * unit;
},
{ passive: false }
);
let dragY = null;
section.addEventListener("pointerdown", (event) => {
if (event.pointerType === "mouse") return; // mice scroll with the wheel
dragY = event.clientY;
section.setPointerCapture(event.pointerId);
});
section.addEventListener("pointermove", (event) => {
if (dragY === null) return;
target -= (event.clientY - dragY) * CONFIG.touchSpeed; // swipe up = scroll down
dragY = event.clientY;
});
const endDrag = () => (dragY = null);
section.addEventListener("pointerup", endDrag);
section.addEventListener("pointercancel", endDrag);
window.addEventListener("keydown", (event) => {
const step = {
ArrowDown: 120,
ArrowUp: -120,
PageDown: window.innerHeight * 0.8,
PageUp: -window.innerHeight * 0.8,
" ": window.innerHeight * 0.8,
}[event.key];
if (step === undefined) return;
event.preventDefault();
target += step;
});
const wrap = (value) => ((value % setHeight) + setHeight) % setHeight;
let last = performance.now();
function frame(now) {
requestAnimationFrame(frame);
const dt = Math.min(now - last, 64) / 1000;
last = now;
current = reduced ? target : current + (target - current) * (1 - Math.exp(-dt / CONFIG.smoothing));
const d = wrap(current * CONFIG.speed);
left.style.transform = `translate3d(0, ${d - setHeight}px, 0)`; // moves down
right.style.transform = `translate3d(0, ${-d}px, 0)`; // moves up
}
requestAnimationFrame(frame);
// Narrower columns are shorter: rebuild on resize.
let resizeTimer = null;
window.addEventListener("resize", () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(build, 150);
});
})();
</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>
원본 구현 프롬프트
# Opposite Scroll: two endless columns of photos that travel in opposite directions as you scroll, passing each other around a still wordmark
**Goal:** build a full-screen, non-scrolling page with two columns of portrait photos and a still wordmark between them. Scrolling moves the left column down and the right column up by the same amount, with a soft eased follow, and both columns loop endlessly in either direction with no visible seam.
## Visual behaviour
1. **Layout:** one viewport-tall section (`height: 100svh`, `overflow: hidden`, `touch-action: none`) on a plain white page. Inside it, a three-column grid: `1fr auto 1fr`. The left and right columns are vertical stacks of 3:4 portrait photos with rounded corners. The middle column holds a single word (the wordmark), vertically centred in the viewport, which never moves.
2. **The columns overflow the viewport.** Each column is a flex column of photos aligned to the top of the section (`align-self: start`), much taller than the screen, and the section clips it. The grid has a single row exactly one viewport tall (`grid-template-rows: 100%`), so the wordmark centres in the screen while the columns hang past it.
3. **Repeated sets:** each column has its own set of 5 photos. Render the set twice, then measure one set's height as the distance from the first photo's top to the top of the first photo of the second copy (this includes the gaps). Keep appending further copies of the set until the column is at least `setHeight + 1.5 × viewport height` tall, so wrapping by one set can never reveal an empty edge.
4. **One endless scroll position:** the page itself never scrolls. Keep a `target` (where the input wants to be, in px, unbounded in both directions) and a `current` (what is rendered). Wheel, touch and keys only change `target`.
5. **Wheel input:** cancel the native wheel (`preventDefault` in a non-passive listener on `window`). Normalise `deltaY`: × 1 for pixel mode (`deltaMode 0`), × 16 for line mode (`deltaMode 1`), × viewport height for page mode (`deltaMode 2`). Add it to `target`.
6. **Touch drags:** on `pointerdown` from a touch or pen (ignore mouse pointers; mice use the wheel), remember the pointer's Y and capture the pointer. On each `pointermove`, `target -= (clientY - previousY) × 1.6` and update the previous Y. Swiping up scrolls down, and the columns move 1.6× as far as the finger, like a flick. Clear the drag on `pointerup` / `pointercancel`. There is no momentum after release; the easing alone finishes the motion.
7. **Keys:** ArrowDown `+120px`, ArrowUp `−120px`, PageDown and Space `+0.8 × viewport height`, PageUp `−0.8 × viewport height`. Call `preventDefault` for these keys only.
8. **Easing:** every animation frame, `current += (target - current) × (1 - e^(-dt / 0.16))`, with `dt` in seconds and capped at 0.064 s (so a stalled tab doesn't jump). This is frame-rate independent exponential smoothing with a 0.16 s time constant: it feels like a scrubbed scroll timeline with about 0.8 s of lag.
9. **Wrap and move:** compute `d = ((current × speed) mod setHeight + setHeight) mod setHeight` (a true modulo, so negative positions wrap too), with `speed = 1`. Then:
- Left column: `translateY(d - setHeight)`, so it moves **down** as you scroll down.
- Right column: `translateY(-d)`, so it moves **up** as you scroll down.
- Because both columns jump by exactly one set when `d` wraps, and the content repeats every set, the loop is seamless. At rest (position 0) the left column starts one set up (`-setHeight`) and the right column starts at 0.
10. **Resize:** column width changes the photo heights, so rebuild both columns (re-measure `setHeight`, refill) on `resize`, debounced by 150 ms. Keep `target` and `current` as they are; the wrap absorbs the new set height.
## Exact values
| What | Value |
|---|---|
| Photos per column set | 5, portrait 3:4, `object-fit: cover` |
| Column speed | 1 px of column travel per px of scroll, both columns, opposite directions |
| Smoothing | `current += (target - current) × (1 - e^(-dt / 0.16))` per frame, dt capped at 0.064 s |
| Wheel | `deltaY` × 1 (pixels), × 16 (lines), × viewport height (pages) |
| Touch drag factor | 1.6 × finger travel, touch and pen pointers only |
| Keys | Arrow ±120 px; PageDown / Space +0.8 vh; PageUp −0.8 vh |
| Wrap | by exactly one set height (first photo to first photo of the next copy, gaps included) |
| Fill | repeat the set until column height ≥ set height + 1.5 × viewport height |
| Left column transform | `translate3d(0, d - setHeight, 0)` (moves down) |
| Right column transform | `translate3d(0, -d, 0)` (moves up) |
| Resize | rebuild columns, debounced 150 ms |
| Grid | `grid-template-columns: 1fr auto 1fr`, `grid-template-rows: 100%`, gap `clamp(12px, 3.4vw, 48px)`, side padding `clamp(16px, 2.5vw, 32px)`, `align-items: center` |
| Photo gap | `clamp(12px, 2.2vw, 32px)` |
| Photo corners | 6px radius, light grey placeholder background `#efefef` |
| Wordmark slot (middle column width) | `calc(clamp(36px, 8.4vw, 132px) × 4.34)` |
| Wordmark type | weight 500, `clamp(20px, 4.4vw, 70px)`, letter-spacing −0.04em, line-height 1, `white-space: nowrap`, centred, black on white |
The wordmark slot is deliberately wider than the word itself: it sets the photo columns' width, and the word sits in it with air on both sides.
## Markup
```html
<section class="duet"> <!-- 100svh, overflow hidden, touch-action none -->
<div class="duet-columns"> <!-- grid: 1fr auto 1fr, one 100% row -->
<div class="duet-column" data-column="down"></div> <!-- filled by script, moves down -->
<p class="duet-wordmark">Studio</p> <!-- still, centred -->
<div class="duet-column" data-column="up"></div> <!-- filled by script, moves up -->
</div>
</section>
```
- Lock the document: `html, body { height: 100%; overflow: hidden; overscroll-behavior: none }`.
- Give the columns `will-change: transform` and write the transforms as `translate3d` so the motion stays on the GPU.
- Give every `<img>` `width` / `height` attributes and `aspect-ratio: 3 / 4`, so the set height can be measured correctly before the images have loaded. Set `draggable="false"` and `user-select: none` so drags don't pick up images or text.
- Use one coherent shoot per column (for example one photographer's series on the left, another on the right) so each column reads as its own story.
## Libraries
None needed: one `requestAnimationFrame` loop, a non-passive `wheel` listener, pointer events and `keydown`. Don't rely on native page scroll or CSS scroll snapping; the position is fully owned by the script.
Optional mappings, if your stack already has them:
- **GSAP:** keep the same `target` / `current` model and use `gsap.ticker` instead of `requestAnimationFrame`, or use `gsap.quickTo` on a proxy value with about 0.8 s duration and `power3.out` ease as an approximation of the exponential follow. Apply the wrap with `gsap.utils.wrap(0, setHeight)`. A pinned ScrollTrigger timeline scrubbing the two columns (`scrub: 0.8`) gives a finite version of the same effect, but not the endless loop.
- **Lenis:** run Lenis with `infinite: true` on a tall wrapper and read its `scroll` value as `current` in the render step; Lenis then provides the wheel and touch smoothing. Keep the modulo wrap and the two opposite transforms.
- In React or Vue, keep `target`, `current` and the set height in refs (not state) and write the transforms directly to the column elements each frame.
## Responsive and reduced motion
- Keep three columns at every width so the wordmark always sits between the two photo columns; the fluid gaps, padding and wordmark sizes scale everything down on phones.
- Touch devices: `touch-action: none` on the section so the browser doesn't scroll, zoom or bounce; vertical drags drive the columns directly.
- On resize (including rotating a phone), rebuild the columns after a 150 ms debounce so the set height and the fill match the new column width.
- `prefers-reduced-motion: reduce`: no smoothing. `current` equals `target` every frame, so the columns follow the input exactly with no eased drift. Everything else stays the same.
## Acceptance criteria
- The page never scrolls; only the two photo columns move. The wordmark stays perfectly still in the centre.
- Scrolling down moves the left column down and the right column up by the same distance; scrolling up reverses both.
- The motion eases in behind the input (about 0.8 s to settle) and is the same speed on 60 Hz and 120 Hz screens.
- Scrolling a long way in either direction never reaches an end and never shows a gap, jump or empty edge at the top or bottom of either column.
- Mouse wheels in line mode, trackpads and page-mode wheels all scroll at a sensible speed.
- On a phone, dragging up moves the columns about 1.6× the finger's travel; mouse clicks and drags do nothing.
- Arrow keys, Page keys and Space scroll the columns.
- Resizing the window rebuilds the columns without gaps.
- With reduced motion, the columns follow the input instantly with no easing.
보관·활용 메모
Cadence에서 제공하는 코드와 프롬프트, 미리보기 영상을 이 사이트에 보관했습니다. 코드는 다운로드한 원본으로 제공하며 실행 화면은 별도로 격리되어 있습니다. 코드의 외부 라이브러리·샘플 이미지 요청은 유지됩니다.
보관일: 2026-10-26 · 프로젝트 적용: 별도 검증 필요
원본 코드·프롬프트·제작자 정보를 함께 보관했습니다. 실제 납품 시 코드와 포함 소재의 이용 조건을 확인하세요.
코드 SHA-256: b8005237c10d1c58db67bf452ae194440852e2ac210068fb8dfa1df74eda9935