모션·인터랙션 / Cadence
Opposing Carousel
내비게이션 · 갤러리 · 스크롤 모션. 미리보기와 원본 코드·프롬프트를 이곳에서 확인하세요.
미리보기 안에서 스크롤하거나 마우스를 움직여 모션을 확인하세요.
HTML · CSS · JavaScript 전체 코드
<!doctype html>
<!--
Opposing Carousel
A list of project names (left) and a column of photos (right) move in OPPOSITE
directions, one step at a time, always centring the same project. Wheel / trackpad,
touch swipes and arrow keys become throttled ±1 steps; a CSS transition glides both
tracks to the new position. The loop never ends: every project exists twice, and a tile
that crosses the wrap seam (far off screen) is moved without animating.
Dependencies: none (plain JavaScript and CSS).
Tweak:
- --dur: 1500ms and --ease: cubic-bezier(0.16, 1, 0.3, 1) (the glide)
- NAME_STEP: 0.18 × viewport height between names (keep --name-step in sync)
- THUMB_GAP: 0.03 × viewport height between photos (step = photo height + gap)
- NAME_SCALE_INACTIVE 0.4, THUMB_SCALE_INACTIVE 0.86, THUMB_OPACITY_NEIGHBOUR 0.5
- STEP_THRESHOLD: 14 (wheel delta per step), STEP_INTERVAL: 55ms between chained steps,
TOUCH_THRESHOLD: 28px of swipe per step
- --thumb-h: 64vh (photo height), 3:4 photos
From the Kendal site (own project).
-->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Opposing Carousel</title>
<style>
*, *::before, *::after { box-sizing: border-box; }
html, body {
height: 100%;
margin: 0;
overflow: hidden;
}
body {
background: #fff;
color: #000;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
.oc-split {
/* Motion: a gentle, long ease-out */
--ease: cubic-bezier(0.16, 1, 0.3, 1);
--dur: 1500ms;
--muted: #8e8e8e;
--line: #e6e6e6;
--gutter: clamp(16px, 2.5vw, 32px);
--max-width: 1440px;
/* The two columns sit in a centred content box (max 1440px, side gutters). */
--box-w: min(100vw, var(--max-width));
--panel-w: calc((var(--box-w) - 2 * var(--gutter)) / 2);
/* Layout rhythm */
--pad-left: max(0px, calc(var(--box-w) * 0.054 - var(--gutter)));
--name-step: 18vh; /* distance between names (the script reads NAME_STEP × vh) */
--thumb-h: 64vh; /* height of the centred photo */
--thumb-w: min(44vw, var(--panel-w), var(--thumb-h) * 3 / 4);
position: fixed;
inset: 0;
display: flex;
justify-content: center;
padding-inline: max(var(--gutter), calc((100vw - var(--max-width)) / 2 + var(--gutter)));
overscroll-behavior: none;
}
.oc-panel {
position: relative;
flex: 0 0 var(--panel-w);
width: var(--panel-w);
height: 100%;
overflow: hidden;
}
/* LEFT: project names */
.oc-names {
position: absolute;
inset: 0;
}
.oc-name {
position: absolute;
top: 50%;
left: var(--pad-left);
white-space: nowrap;
text-align: left;
/* Scale about the left edge so active and inactive titles share one alignment. */
transform-origin: left center;
transform: translateY(-50%);
transition: transform var(--dur) var(--ease), opacity var(--dur) var(--ease);
will-change: transform, opacity;
}
.oc-name__title {
display: block;
font-weight: 400;
line-height: 1.02;
color: var(--muted);
/* All titles share the active size; inactive ones are scaled down by the parent. */
font-size: clamp(2.6rem, 5vw, 4.6rem);
letter-spacing: -0.01em;
transition: color var(--dur) var(--ease);
}
.oc-name__tag {
display: block;
margin-top: 1.1rem;
font-weight: 500;
font-size: 0.7rem;
letter-spacing: 0.25em;
text-transform: uppercase;
color: var(--muted);
opacity: 0;
/* Fast fade-out: gone early in the transition. */
transition: opacity 200ms ease-out;
}
.oc-name.is-active .oc-name__title { color: #000; }
.oc-name.is-active .oc-name__tag {
opacity: 1;
/* Slow fade-in when the project becomes active. */
transition: opacity var(--dur) var(--ease);
}
/* Static hairlines framing the active title */
.oc-hairline {
position: absolute;
left: var(--pad-left);
width: min(21rem, var(--panel-w) - var(--pad-left) - 12px);
height: 1px;
background: var(--line);
z-index: 5;
}
.oc-hairline--top { top: calc(50% - var(--name-step) * 0.7); }
.oc-hairline--bottom { top: calc(50% + var(--name-step) * 0.7); }
/* RIGHT: photo carousel */
.oc-carousel {
position: absolute;
inset: 0;
}
.oc-thumb {
position: absolute;
top: 50%;
left: 50%;
width: var(--thumb-w);
height: calc(var(--thumb-w) * 4 / 3);
transform: translate(-50%, -50%);
transition: transform var(--dur) var(--ease), opacity var(--dur) var(--ease);
will-change: transform, opacity;
overflow: hidden;
border-radius: 6px;
background: #efefef;
}
.oc-thumb img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
user-select: none;
-webkit-user-drag: none;
}
/* Phones: smaller titles so the longest name fits the half-width panel */
@media (max-width: 700px) {
.oc-name__title { font-size: 6.2vw; }
.oc-name__tag {
margin-top: 0.7rem;
font-size: 9px;
letter-spacing: 0.15em;
}
}
@media (prefers-reduced-motion: reduce) {
.oc-name,
.oc-name__title,
.oc-name__tag,
.oc-name.is-active .oc-name__tag,
.oc-thumb {
transition-duration: 0s;
}
}
</style>
</head>
<body>
<!-- Names on the left, photos on the right. The script builds both tracks from PROJECTS. -->
<div class="oc-split">
<section class="oc-panel" aria-label="Projects">
<div class="oc-names" id="names" aria-live="polite"></div>
<span class="oc-hairline oc-hairline--top"></span>
<span class="oc-hairline oc-hairline--bottom"></span>
</section>
<section class="oc-panel" aria-label="Project images">
<div class="oc-carousel" id="carousel"></div>
</section>
</div>
<script>
const NAME_STEP = 0.18; // × viewport height between names
const THUMB_GAP = 0.03; // × viewport height between photos
const NAME_SCALE_INACTIVE = 0.4;
const THUMB_SCALE_INACTIVE = 0.86;
const THUMB_OPACITY_NEIGHBOUR = 0.5;
const STEP_THRESHOLD = 14; // wheel delta needed to commit a step
const STEP_INTERVAL = 55; // min ms between chained steps
const TOUCH_THRESHOLD = 28; // px of touch travel per step
// Swap the picsum.photos placeholders for your own 3:4 portrait images.
const PROJECTS = [
{ name: "Courtyard", category: "Interiors", img: "https://picsum.photos/seed/cadence-1/1200/1600", alt: "" },
{ name: "Linen", category: "Editorial", img: "https://picsum.photos/seed/cadence-2/1200/1600", alt: "" },
{ name: "Lighthouse", category: "Landscape", img: "https://picsum.photos/seed/cadence-3/1200/1600", alt: "" },
{ name: "Camel Coat", category: "Portrait", img: "https://picsum.photos/seed/cadence-4/1200/1600", alt: "" },
{ name: "Olive Grove", category: "Landscape", img: "https://picsum.photos/seed/cadence-5/1200/1600", alt: "" },
{ name: "Winter", category: "Portrait", img: "https://picsum.photos/seed/cadence-6/1200/1600", alt: "" },
{ name: "Stairwell", category: "Editorial", img: "https://picsum.photos/seed/cadence-7/1200/1600", alt: "" },
{ name: "Gallery", category: "Architecture", img: "https://picsum.photos/seed/cadence-8/1200/1600", alt: "" },
];
/* StepScroll: turns continuous wheel / trackpad / touch input into discrete
one-project steps, but allows rapid chaining. Each step retargets an integer
position; the CSS transition glides to the latest target and always settles on a
whole project. */
class StepScroll {
constructor({ target, onStep, threshold = STEP_THRESHOLD, interval = STEP_INTERVAL }) {
this.target = target;
this.onStep = onStep;
this.threshold = threshold;
this.interval = interval;
this.accum = 0;
this.last = 0;
this.touchY = null;
this.target.addEventListener("wheel", this._onWheel, { passive: false });
this.target.addEventListener("touchstart", this._onTouchStart, { passive: true });
this.target.addEventListener("touchmove", this._onTouchMove, { passive: false });
this.target.addEventListener("touchend", this._onTouchEnd, { passive: true });
window.addEventListener("keydown", this._onKey);
}
// Commit one step, throttled so a fast fling chains smoothly instead of
// leaping many projects at once.
_commit(dir) {
const now = performance.now();
if (now - this.last < this.interval) return false;
this.last = now;
this.accum = 0;
this.onStep(dir);
return true;
}
_onWheel = (e) => {
e.preventDefault();
this.accum += e.deltaY;
if (this.accum >= this.threshold) this._commit(1);
else if (this.accum <= -this.threshold) this._commit(-1);
};
_onTouchStart = (e) => {
this.touchY = e.touches[0].clientY;
};
_onTouchMove = (e) => {
if (this.touchY === null) return;
e.preventDefault();
const dy = this.touchY - e.touches[0].clientY;
if (Math.abs(dy) > TOUCH_THRESHOLD) {
if (this._commit(dy > 0 ? 1 : -1)) this.touchY = e.touches[0].clientY;
}
};
_onTouchEnd = () => {
this.touchY = null;
};
_onKey = (e) => {
if (e.key === "ArrowDown" || e.key === "PageDown") this._commit(1);
else if (e.key === "ArrowUp" || e.key === "PageUp") this._commit(-1);
};
}
/* Build the DOM: two tiles per project, content by modulo */
const namesEl = document.getElementById("names");
const carouselEl = document.getElementById("carousel");
const N = PROJECTS.length;
// Two copies of every project, so a hidden buffer is always available on each side
// and the seam where a tile wraps stays off screen.
const M = N * 2;
const nameEls = [];
const thumbEls = [];
for (let t = 0; t < M; t++) {
const p = PROJECTS[t % N];
const nameEl = document.createElement("div");
nameEl.className = "oc-name";
nameEl.innerHTML = `<span class="oc-name__title">${p.name}</span><span class="oc-name__tag">${p.category}</span>`;
if (t >= N) nameEl.setAttribute("aria-hidden", "true");
namesEl.appendChild(nameEl);
nameEls.push(nameEl);
const thumbEl = document.createElement("div");
thumbEl.className = "oc-thumb";
thumbEl.innerHTML = `<img src="${p.img}" alt="${t < N ? p.alt : ""}" width="1200" height="1600" draggable="false">`;
carouselEl.appendChild(thumbEl);
thumbEls.push(thumbEl);
}
/* State */
let pos = 0; // unbounded position counter
const prevRel = new Array(M).fill(null);
let nameStep = 0; // px between names
let thumbStep = 0; // px between photos
function measure() {
const vh = window.innerHeight;
nameStep = vh * NAME_STEP;
thumbStep = thumbEls[0].offsetHeight + vh * THUMB_GAP;
}
// Nearest signed distance of tile t to the current position, in (-M/2, M/2].
function relOf(t) {
let rel = (((t - pos) % M) + M) % M; // 0 .. M-1
if (rel > M / 2) rel -= M;
return rel;
}
function applyTile(t, rel) {
const dist = Math.abs(rel);
// LEFT name: the active one enters from the BOTTOM when advancing.
const nameEl = nameEls[t];
const nameScale = rel === 0 ? 1 : NAME_SCALE_INACTIVE;
nameEl.style.transform = `translateY(calc(-50% + ${rel * nameStep}px)) scale(${nameScale})`;
nameEl.classList.toggle("is-active", rel === 0);
nameEl.style.opacity = dist <= 1 ? "1" : "0";
// RIGHT photo: the active one enters from the TOP (the opposite direction).
const thumbEl = thumbEls[t];
const scale = rel === 0 ? 1 : THUMB_SCALE_INACTIVE;
thumbEl.style.transform = `translate(-50%, calc(-50% + ${-rel * thumbStep}px)) scale(${scale})`;
thumbEl.style.opacity = rel === 0 ? "1" : dist === 1 ? String(THUMB_OPACITY_NEIGHBOUR) : "0";
thumbEl.style.zIndex = rel === 0 ? "2" : "1";
}
function render() {
for (let t = 0; t < M; t++) {
const rel = relOf(t);
// A tile that jumped to the far side of the ring (the wrap seam) moves without
// animating: it is deep off screen and hidden.
const wrapped = prevRel[t] !== null && Math.abs(rel - prevRel[t]) > 1;
if (wrapped) {
nameEls[t].style.transition = "none";
thumbEls[t].style.transition = "none";
applyTile(t, rel);
void nameEls[t].offsetWidth; // commit the jump, then restore transitions
nameEls[t].style.transition = "";
thumbEls[t].style.transition = "";
} else {
applyTile(t, rel);
}
prevRel[t] = rel;
}
}
measure();
render();
new StepScroll({
target: document.body,
onStep: (dir) => {
pos += dir;
render();
},
});
window.addEventListener("resize", () => {
measure();
render();
});
</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>
원본 구현 프롬프트
# Opposing Carousel: names and photos that step in opposite directions
**Goal:** build a full-screen, endless project carousel split in two halves: a list of project names on the left and a column of photos on the right. Each scroll, swipe or arrow key moves both one step, in opposite directions, always centring the same project.
## Visual behaviour
1. **Layout.** A fixed, full-viewport stage that never scrolls, white background. Two equal columns side by side inside a centred content box (max 1440px wide, side gutters `clamp(16px, 2.5vw, 32px)`). Each column hides its overflow.
2. **Left column: names.** Project names stacked vertically, `18vh` apart, left aligned. The active project sits at the vertical centre: full size, black, with a small uppercase category label under it. Its two neighbours (one above, one below) are visible at `scale(0.4)` (scaled from their left edge) in grey `#8E8E8E`. All other names are at opacity 0. Two static 1px hairlines (`#E6E6E6`, up to 21rem wide) frame the active name, at `50% ± 0.7 × 18vh`.
3. **Right column: photos.** 3:4 portrait photos, centred horizontally, the active one at the vertical centre: `64vh` tall (narrower if the column is too small), rounded 6px corners. The step between photos is the photo height + `3vh`. Neighbours (one above, one below) are at `scale(0.86)` and opacity 0.5, peeking in at the top and bottom edges. Others are at opacity 0. The active photo has a higher z-index.
4. **Opposite directions.** Advancing (scroll down, swipe up, ArrowDown or PageDown) moves position `+1`:
- Names move **up**: the next name rises from below into the centre (name offset = `rel × nameStep`, where `rel` is the item's distance from the active one).
- Photos move **down**: the next photo comes in from above (photo offset = `−rel × photoStep`).
- Going back does the reverse.
5. **The glide.** Every change is a CSS transition on `transform` and `opacity`: **1500ms, `cubic-bezier(0.16, 1, 0.3, 1)`** (a long, soft ease-out). Scale changes (0.4 ↔ 1, 0.86 ↔ 1) happen in the same transition. The name colour changes grey ↔ black with the same timing. The category label fades out fast (200ms ease-out) when its project stops being active, and fades in slowly (1500ms, same curve) when it becomes active.
6. **Discrete steps.** Input never moves the tracks continuously; it only changes an integer position, and the transition always settles on a whole project.
- Wheel / trackpad: accumulate `deltaY`; when it reaches `+14` commit a step forward, at `−14` a step back, then reset the accumulator. `preventDefault()` the wheel.
- Steps are throttled to at most one every **55ms**, so a fast fling chains several projects in a row instead of jumping or stalling. A new step simply retargets the running transition.
- Touch: a vertical swipe of more than 28px commits a step (swipe up = forward), then measures again from the current finger position.
- Keys: ArrowDown / PageDown forward, ArrowUp / PageUp back.
7. **Endless loop.** Render every project twice (2N tiles for N projects), content chosen by `tile % N`. For each tile, compute its signed distance to the position: `rel = ((t − pos) mod 2N)`, then subtract `2N` if `rel > N`. When a tile's `rel` jumps by more than 1 between renders (it crossed the wrap seam, far off screen and invisible), disable its transition, apply the new transform, force a reflow, and restore the transition, so it teleports instead of sweeping across the screen.
## Exact values
| What | Value |
|---|---|
| Transition | 1500ms `cubic-bezier(0.16, 1, 0.3, 1)` on transform and opacity |
| Name step | 18vh |
| Inactive name | `scale(0.4)`, origin left center, grey `#8E8E8E`; only ±1 visible |
| Active name | `font-size: clamp(2.6rem, 5vw, 4.6rem)`, weight 400, black, letter spacing −0.01em |
| Category label | 0.7rem, weight 500, uppercase, letter spacing 0.25em, grey, 1.1rem below the name; fade out 200ms ease-out, fade in 1500ms |
| Name left inset | `max(0px, contentBoxWidth × 0.054 − gutter)` |
| Photo | 3:4, height 64vh (width `min(44vw, columnWidth, 64vh × 3/4)`), radius 6px, `object-fit: cover` |
| Photo step | photo height + 3vh |
| Neighbour photo | `scale(0.86)`, opacity 0.5 |
| Wheel threshold | 14 (deltaY) per step |
| Step throttle | 55ms |
| Touch threshold | 28px |
## Markup and structure
```html
<div class="oc-split"> <!-- position: fixed; inset: 0; display: flex -->
<section class="oc-panel"> <!-- left half, overflow hidden -->
<div class="oc-names">
<div class="oc-name"><span class="oc-name__title">Courtyard</span><span class="oc-name__tag">Interiors</span></div>
… <!-- 2 × N names -->
</div>
<span class="oc-hairline oc-hairline--top"></span>
<span class="oc-hairline oc-hairline--bottom"></span>
</section>
<section class="oc-panel"> <!-- right half, overflow hidden -->
<div class="oc-carousel">
<div class="oc-thumb"><img src="…" alt="…"></div>
… <!-- 2 × N photos -->
</div>
</section>
</div>
```
- Every name and photo is absolutely positioned at the column's vertical centre (`top: 50%`), and the script sets only its `transform`, `opacity`, `z-index` and an `is-active` class:
- name: `translateY(calc(-50% + rel × nameStep px)) scale(rel === 0 ? 1 : 0.4)`, opacity 1 if `|rel| ≤ 1`, else 0
- photo: `translate(-50%, calc(-50% + −rel × photoStep px)) scale(rel === 0 ? 1 : 0.86)`, opacity 1 / 0.5 / 0
- Measure `nameStep` (0.18 × innerHeight) and `photoStep` (photo `offsetHeight` + 0.03 × innerHeight) on load and on resize, then re-render.
- The duplicate names get `aria-hidden="true"`; the names container can be `aria-live="polite"`.
## Libraries
- None: plain JavaScript and CSS transitions. Interrupting a CSS transition by setting a new transform makes chained steps flow naturally.
- With GSAP, you could tween each tile to its target with `duration: 1.5, ease: "expo.out"` and `overwrite: true` instead of CSS transitions.
## Responsive and reduced motion
- Below 700px: name size `6.2vw`, label 9px with 0.15em letter spacing, label gap 0.7rem. The layout stays two columns.
- The stage is fixed and `overscroll-behavior: none`, so wheel and touch never scroll the page.
- `prefers-reduced-motion: reduce`: all transition durations are 0s. Steps still work and jump straight to the new state.
## Acceptance criteria
- One wheel notch, a short swipe or one arrow key press moves exactly one project.
- Names travel up while photos travel down when advancing, and vice versa when going back; the active name and active photo always belong to the same project.
- Every movement glides for 1.5s with a soft ease-out and always ends exactly centred.
- A fast trackpad fling chains several steps smoothly (no more than one per 55ms) without skipping states visibly or getting stuck mid-way.
- The loop is endless in both directions, with no visible jump at the wrap point.
- The active name is black with its category visible; neighbours are small, grey and framed outside the two hairlines.
보관·활용 메모
Cadence에서 제공하는 코드와 프롬프트, 미리보기 영상을 이 사이트에 보관했습니다. 코드는 다운로드한 원본으로 제공하며 실행 화면은 별도로 격리되어 있습니다. 코드의 외부 라이브러리·샘플 이미지 요청은 유지됩니다.
보관일: 2026-10-26 · 프로젝트 적용: 별도 검증 필요
원본 코드·프롬프트·제작자 정보를 함께 보관했습니다. 실제 납품 시 코드와 포함 소재의 이용 조건을 확인하세요.
코드 SHA-256: b63df440aac9e799c2f8e4d7244641a10365490938fa494fc8ba29786ab924e3