모션·인터랙션 / Cadence
Circular Gallery
갤러리 · 3D · 내비게이션 모션. 미리보기와 원본 코드·프롬프트를 이곳에서 확인하세요.
미리보기 안에서 스크롤하거나 마우스를 움직여 모션을 확인하세요.
HTML · CSS · JavaScript 전체 코드
<!doctype html>
<!--
Circular Gallery
An endless strip of portrait photos set along a wide downward curve, seen from the
outside. Drag or scroll to spin it; on release it glides on with inertia, and it always
keeps drifting slowly on its own. Each photo has a position on an endless strip (wrapped
with modulo) that maps onto the curve: photos sink (y = -bend * x²) and tilt tangent to
the curve as they move away from the centre. Flat cards with 6px rounded corners, cut in
the fragment shader. The photo nearest the centre shows its caption under the strip.
Dependencies: three@0.182.0 (ES module from jsDelivr, via the import map). No build step.
Tweak (CONFIG at the top of the script):
- width 1.2 / aspect 3:4 / gap 0.5: photo size and spacing in world units
- bend 0.09: curvature (y drops by bend * x²)
- dragSpeed 0.008 world units per px, wheelSpeed 0.004 per wheel delta
- inertia 1.6: how fast the release glide dies out (exponential damping)
- idleSpeed 0.35 world units per second: the constant drift
- cameraZ 9, FOV 35°, CAMERA_Y -0.4; the camera pulls back 1.6× on portrait screens
- radiusPx 6: corner radius in CSS pixels
Based on "Circular Gallery" from The Animations Index (own project).
-->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Circular Gallery</title>
<style>
html, body { height: 100%; margin: 0; overflow: hidden; background: #fff; }
.stage {
position: fixed;
inset: 0;
overflow: hidden;
touch-action: none; /* every touch drag spins the strip */
cursor: grab;
-webkit-user-select: none;
user-select: none;
}
.stage.is-dragging { cursor: grabbing; }
.canvas { position: absolute; inset: 0; display: block; width: 100%; height: 100%; }
/* Caption of the photo nearest the centre. */
.caption {
position: absolute;
bottom: 40px;
left: 0;
right: 0;
margin: 0;
padding-left: 0.25em; /* balances the trailing letter spacing */
font: 500 12px/1.6 system-ui, -apple-system, "Segoe UI", sans-serif;
letter-spacing: 0.25em;
text-transform: uppercase;
color: #8e8e8e;
text-align: center;
pointer-events: none;
}
/* Hairline loader: fills while the photos load, then fades out. */
.loader {
position: fixed;
inset: 0;
display: grid;
place-items: center;
background: #fff;
transition: opacity 0.6s ease;
}
.loader.is-done { opacity: 0; pointer-events: none; }
.loader-track { width: 160px; height: 1px; overflow: hidden; background: #e6e6e6; }
.loader-bar {
display: block;
height: 100%;
background: #000;
transform: scaleX(0);
transform-origin: left;
transition: transform 0.3s ease;
}
</style>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.182.0/build/three.module.js"
}
}
</script>
</head>
<body>
<div class="stage" data-stage>
<canvas class="canvas" data-canvas role="img" aria-label="An arc of photographs. Drag or scroll to spin it."></canvas>
<p class="caption" data-caption aria-live="off"></p>
</div>
<div class="loader" data-loader aria-hidden="true">
<div class="loader-track"><span class="loader-bar" data-loader-bar></span></div>
</div>
<script type="module">
import * as THREE from "three";
const CONFIG = {
width: 1.2, // photo width in world units
aspect: 3 / 4, // portrait photos
gap: 0.5, // space between photos (≈ 40% of a photo's width)
bend: 0.09, // curvature: y drops by bend * x²
dragSpeed: 0.008, // world units per px
wheelSpeed: 0.004,
inertia: 1.6,
idleSpeed: 0.35, // world units per second
cameraZ: 9,
radiusPx: 6, // corner radius in CSS pixels
};
const FOV = 35;
const CAMERA_Y = -0.4;
const MOBILE_ZOOM = 1.6; // camera pulls back by this factor on portrait screens
const PIXEL_RATIO_MAX = 2;
// Photos: swap the picsum.photos placeholders for your own 3:4 images (about 600 × 800 is plenty).
const LABELS = [
"Linen", "Sea Wall", "Olive Grove", "Rust Coat", "Gallery", "White Shirt", "Pier in Winter",
"Harbour", "Lavender", "Crossing", "Window Light", "Afternoon", "Chalk Cliffs", "Low Tide",
"Camel", "Overcast", "Lighthouse", "Reading", "Courtyard", "Portrait", "Limestone Bay",
"Turning", "Reading Room", "Bench", "Wildflowers", "Off Duty", "Doorway", "Two of Us",
];
const PHOTOS = LABELS.map((label, i) => ({
src: `https://picsum.photos/seed/cadence-${i + 1}/600/800`,
label,
}));
/* Shaders · a flat plane with rounded corners */
const vertexShader = /* glsl */ `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
const fragmentShader = /* glsl */ `
uniform sampler2D uTexture;
uniform vec2 uCover;
uniform float uFade;
uniform vec2 uSize; // plane size in world units
uniform float uRadius; // corner radius in world units
uniform float uPixel; // one CSS pixel in world units, for the soft edge
varying vec2 vUv;
float roundedBox(vec2 p, vec2 halfSize, float r) {
vec2 q = abs(p) - halfSize + r;
return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r;
}
void main() {
vec4 color = texture2D(uTexture, (vUv - 0.5) * uCover + 0.5);
float d = roundedBox((vUv - 0.5) * uSize, uSize * 0.5, uRadius);
float alpha = 1.0 - smoothstep(-uPixel * 0.5, uPixel * 0.5, d);
gl_FragColor = vec4(mix(vec3(0.937), color.rgb, uFade), alpha); // light grey until loaded
}
`;
/* Setup */
const stage = document.querySelector("[data-stage]");
const canvas = document.querySelector("[data-canvas]");
const caption = document.querySelector("[data-caption]");
const loaderEl = document.querySelector("[data-loader]");
const bar = document.querySelector("[data-loader-bar]");
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const lerp = (a, b, t) => a + (b - a) * t;
const damp = (current, target, lambda, delta) => lerp(current, target, 1 - Math.exp(-lambda * delta));
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, PIXEL_RATIO_MAX));
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(FOV, 1, 0.1, 50);
camera.position.set(0, CAMERA_Y, CONFIG.cameraZ);
camera.lookAt(0, CAMERA_Y, 0);
const manager = new THREE.LoadingManager();
manager.onProgress = (url, loaded, total) => {
bar.style.transform = `scaleX(${loaded / total})`;
};
manager.onLoad = () => {
loaderEl.classList.add("is-done");
loaderEl.addEventListener("transitionend", () => loaderEl.remove(), { once: true });
};
const loader = new THREE.TextureLoader(manager);
loader.setCrossOrigin("anonymous");
const height = CONFIG.width / CONFIG.aspect;
const step = CONFIG.width + CONFIG.gap;
const total = step * PHOTOS.length;
const geometry = new THREE.PlaneGeometry(CONFIG.width, height);
const corner = { radius: { value: 0 }, pixel: { value: 0.01 } }; // shared by every plane
const photos = PHOTOS.map(({ src, label }, i) => {
const uniforms = {
uTexture: { value: null },
uCover: { value: new THREE.Vector2(1, 1) },
uFade: { value: 0 },
uSize: { value: new THREE.Vector2(CONFIG.width, height) },
uRadius: corner.radius,
uPixel: corner.pixel,
};
const material = new THREE.ShaderMaterial({ vertexShader, fragmentShader, uniforms, transparent: true });
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);
loader.load(src, (texture) => {
uniforms.uTexture.value = texture;
// object-fit: cover inside the plane
const aspect = texture.image.width / texture.image.height;
const plane = CONFIG.aspect;
if (plane > aspect) uniforms.uCover.value.set(1, aspect / plane);
else uniforms.uCover.value.set(plane / aspect, 1);
uniforms.uFade.value = 1;
});
return { mesh, label, base: i * step };
});
function resize() {
const w = stage.clientWidth;
const h = stage.clientHeight;
renderer.setSize(w, h, false);
camera.aspect = w / h;
camera.updateProjectionMatrix();
camera.position.z = CONFIG.cameraZ * (camera.aspect < 1 ? MOBILE_ZOOM : 1);
// How many world units one CSS pixel is at the strip's depth, for the corners.
const worldHeight = 2 * camera.position.z * Math.tan(THREE.MathUtils.degToRad(FOV / 2));
corner.pixel.value = worldHeight / h;
corner.radius.value = CONFIG.radiusPx * corner.pixel.value;
}
window.addEventListener("resize", resize);
resize();
/* Input · drag, wheel, inertia */
let scroll = 0;
let velocity = 0;
let dragging = false;
let lastX = 0;
let lastTime = 0;
stage.addEventListener("pointerdown", (event) => {
dragging = true;
lastX = event.clientX;
lastTime = performance.now();
velocity = 0;
stage.classList.add("is-dragging");
stage.setPointerCapture(event.pointerId);
});
stage.addEventListener("pointermove", (event) => {
if (!dragging) return;
const now = performance.now();
const dt = Math.max(now - lastTime, 1) / 1000;
const dx = (event.clientX - lastX) * CONFIG.dragSpeed;
scroll -= dx;
velocity = -dx / dt;
lastX = event.clientX;
lastTime = now;
});
const stop = () => {
dragging = false;
if (reduced) velocity = 0; // no release glide with reduced motion
stage.classList.remove("is-dragging");
};
stage.addEventListener("pointerup", stop);
stage.addEventListener("pointercancel", stop);
stage.addEventListener("wheel", (event) => {
scroll += (event.deltaY + event.deltaX) * CONFIG.wheelSpeed;
event.preventDefault();
}, { passive: false });
/* Frame · place every photo on the curve */
let lastLabel = "";
function frame(delta) {
if (!dragging) {
velocity = damp(velocity, 0, CONFIG.inertia, delta);
scroll += (velocity + (reduced ? 0 : CONFIG.idleSpeed)) * delta;
}
let nearest = null;
photos.forEach((p) => {
// Wrap into [-total/2, total/2) so the strip is endless.
const x = ((p.base - scroll) % total + total * 1.5) % total - total / 2;
const y = -CONFIG.bend * x * x;
p.mesh.position.set(x, y, 0);
p.mesh.rotation.z = Math.atan(-2 * CONFIG.bend * x); // tangent to the curve
if (!nearest || Math.abs(x) < Math.abs(nearest.x)) nearest = { x, photo: p };
});
if (nearest && nearest.photo.label !== lastLabel) {
lastLabel = nearest.photo.label;
caption.textContent = lastLabel;
}
renderer.render(scene, camera);
}
// requestAnimationFrame loop, paused while the tab is hidden; delta capped at 0.1 s.
let frameId = null;
let lastNow = 0;
function tick(now) {
const delta = lastNow ? Math.min((now - lastNow) / 1000, 0.1) : 0;
lastNow = now;
frame(delta);
frameId = requestAnimationFrame(tick);
}
document.addEventListener("visibilitychange", () => {
if (document.hidden) {
cancelAnimationFrame(frameId);
frameId = null;
} else if (frameId === null) {
lastNow = 0;
frameId = requestAnimationFrame(tick);
}
});
frameId = requestAnimationFrame(tick);
</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>
원본 구현 프롬프트
# Circular Gallery: an endless strip of photos on a wide curve that you drag or scroll to spin
**Goal:** build a full-screen WebGL gallery where portrait photos sit on an endless strip bent along a wide downward arc. The strip drifts slowly on its own; dragging or scrolling spins it, with an inertial glide on release.
## Visual behaviour
1. A full-viewport canvas on a white background. The page itself never scrolls.
2. **The strip.** 28 portrait photos (3:4) in a row, equally spaced, flat cards with 6px rounded corners. The row is endless: when a photo leaves one side it comes back on the other.
3. **The curve.** Each photo's horizontal position `x` (world units, 0 at the centre of the screen) decides its height and tilt:
- It sinks with distance: `y = -bend * x²` with `bend = 0.09`, so the centre photo is highest and the strip falls away to both sides like the top of a big wheel.
- It rotates around its own centre to stay tangent to the curve: `rotationZ = atan(-2 * bend * x)`. Cards on the left lean left, cards on the right lean right.
- The cards stay flat (no bending of the plane itself).
4. **Idle drift.** The strip always moves to the left at a constant `0.35` world units per second (except with reduced motion).
5. **Drag.** Pointer down anywhere starts a drag (cursor `grab` → `grabbing`). Horizontal movement moves the strip with the pointer: `0.008` world units per pixel. Vertical movement is ignored.
6. **Release glide.** While dragging, track velocity (world units per second) from the last move. On release, the strip keeps that velocity and it decays exponentially: each frame `velocity = lerp(velocity, 0, 1 - exp(-1.6 * dt))`. The idle drift is added on top, so the strip always ends up drifting again.
7. **Wheel / trackpad.** `(deltaY + deltaX) * 0.004` world units per wheel event, applied directly (no inertia). Prevent the default so the page doesn't bounce.
8. **Caption.** Under the strip, centred 40px from the bottom of the screen, show the label of the photo closest to the centre. Small uppercase label: 12px, weight 500, letter spacing `0.25em`, grey `#8E8E8E`. The text swaps instantly when another photo becomes the nearest.
9. **Loading.** Until a photo's texture has loaded, its card is light grey (`#EFEFEF`). A thin hairline progress bar (160 × 1px, centred) fills as the textures load and fades out (0.6 s) when all are loaded.
## Exact values
| What | Value |
|---|---|
| Photos | 28, ratio 3:4, cover-cropped into the card |
| Card size | 1.2 world units wide, 1.6 tall |
| Gap | 0.5 world units (about 40% of a card's width) |
| Spacing (step) | 1.7 world units; strip length = 1.7 × 28 = 47.6 |
| Curve | `y = -0.09 · x²`, tilt `atan(-0.18 · x)` |
| Camera | perspective, FOV 35°, at (0, -0.4, 9) looking at (0, -0.4, 0) |
| Portrait screens | camera z × 1.6 (pulls back) when width < height |
| Idle drift | 0.35 world units/s |
| Drag | 0.008 world units per px |
| Wheel | 0.004 world units per delta unit |
| Inertia | exponential damping, λ = 1.6 |
| Corners | 6 CSS px, converted to world units on resize |
| Frame delta | capped at 0.1 s; pause the loop while the tab is hidden |
| Pixel ratio | capped at 2 |
## Wrapping
For each photo, `base = index * step`. Each frame:
```
x = ((base - scroll) mod total + total * 1.5) mod total - total / 2
```
This keeps every photo in `[-total/2, total/2)`, so the strip loops with no visible seam (the wrap happens far off-screen).
## Rounded corners in the shader
Draw each card as a plane with a custom fragment shader:
- Sample the texture with cover UVs: `(uv - 0.5) * cover + 0.5`, where `cover` shrinks the axis that is too long for the 3:4 plane.
- Compute a rounded-box signed distance in world units with the plane size and the corner radius, and set alpha with `1 - smoothstep(-px/2, px/2, d)` (one CSS pixel of anti-aliasing). Enable transparency.
- One CSS pixel in world units at the strip's depth: `2 · cameraZ · tan(FOV/2) / viewportHeight`. Radius = 6 × that. Recompute on resize.
## Markup
```html
<div class="stage"> <!-- fixed, inset 0, touch-action: none, cursor: grab -->
<canvas></canvas> <!-- fills the stage -->
<p class="caption"></p> <!-- absolute, bottom 40px, centred, pointer-events: none -->
</div>
<div class="loader"><div class="track"><span class="bar"></span></div></div>
```
## Libraries
- **Three.js** (r182): `WebGLRenderer({ antialias: true, alpha: true })`, one shared `PlaneGeometry(1.2, 1.6)`, a `ShaderMaterial` per photo, `TextureLoader` with a `LoadingManager` for the progress bar.
- **Plain alternative:** the same layout works with absolutely positioned `<img>` elements and CSS transforms: convert world units to pixels and set `translate(x, y) rotate(angle)` per frame. You lose perspective, which matters little here since the cards stay flat at one depth.
## Responsive and reduced motion
- On portrait screens the camera pulls back 1.6×, so more of the arc fits.
- `touch-action: none` on the stage, so a finger drag spins the strip instead of scrolling.
- `prefers-reduced-motion: reduce`: no idle drift and no release glide. The strip only moves while it is being dragged or scrolled.
## Acceptance criteria
- On load the strip drifts slowly left along a downward arc; the centre card is upright and highest, cards further out sink and lean outwards.
- Dragging moves the strip with the pointer; releasing a fast drag glides on and slows down smoothly, then the slow drift continues.
- Scrolling the wheel spins the strip in the scroll direction.
- The strip is endless in both directions with no gaps or jumps.
- Cards have crisp 6px rounded corners at any screen size.
- The caption under the strip always names the card nearest the centre.
- With reduced motion, nothing moves unless you drag or scroll.
보관·활용 메모
Cadence에서 제공하는 코드와 프롬프트, 미리보기 영상을 이 사이트에 보관했습니다. 코드는 다운로드한 원본으로 제공하며 실행 화면은 별도로 격리되어 있습니다. 코드의 외부 라이브러리·샘플 이미지 요청은 유지됩니다.
보관일: 2026-10-26 · 프로젝트 적용: 별도 검증 필요
원본 코드·프롬프트·제작자 정보를 함께 보관했습니다. 실제 납품 시 코드와 포함 소재의 이용 조건을 확인하세요.
코드 SHA-256: f36d9dba63c8274599fe3929c94617146c55f746f09873891d174383159b97c2