모션·인터랙션 / Cadence
Slice Slider
갤러리 · 이미지 모션. 미리보기와 원본 코드·프롬프트를 이곳에서 확인하세요.
미리보기 안에서 스크롤하거나 마우스를 움직여 모션을 확인하세요.
HTML · CSS · JavaScript 전체 코드
<!doctype html>
<!--
Slice Slider
A slider that cuts each photo into vertical strips. On change, the current photo's strips
slide away up and down alternately while the next photo's strips slide in from the
opposite side, staggered from the edge. Previous / Next buttons, arrow and page keys,
horizontal drags on the slider, vertical swipes anywhere and the mouse wheel / trackpad
all change the slide (one step per gesture).
Dependencies: GSAP 3.15 + Observer, from jsDelivr.
Tweak (CONFIG in the script):
- strips 9; duration 1.0 s; stagger 0.05 s (from the left going forward, from the
right going back); ease "power3.inOut"; the new strips start 0.08 s after the old ones
- overshoot 8 (% extra travel so strips leave the frame fully: they move 108 %)
- WHEEL.threshold 30 px, WHEEL.quiet 220 ms: one wheel gesture = one step
- SWIPE.distance 40 px, SWIPE.velocity 300 px/s: what a drag or swipe needs
Reduced motion: a 0.4 s crossfade instead.
Based on "Slice Slider", from The Animations Index, 38 Slice Slider (own project).
GSAP is free under the GSAP standard licence (https://gsap.com).
-->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Slice Slider</title>
<style>
*, *::before, *::after { box-sizing: border-box; }
body {
display: flex;
flex-direction: column;
min-height: 100vh;
min-height: 100svh;
margin: 0;
background: #fff;
color: #000;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
-webkit-font-smoothing: antialiased;
}
.stage {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 24px;
padding: 24px clamp(16px, 2.5vw, 32px);
touch-action: none; /* swipes anywhere change the slide (the page itself never scrolls) */
}
.slider {
position: relative;
width: min(100%, 1100px, (100svh - 160px) * 1.5); /* 3:2, never taller than the screen allows */
aspect-ratio: 3 / 2;
overflow: hidden;
border-radius: 6px;
background: #efefef;
cursor: grab;
user-select: none;
}
.slider:active { cursor: grabbing; }
.slider__stage,
.slide {
position: absolute;
inset: 0;
}
.strip {
position: absolute;
top: 0;
height: 100%;
background-size: cover;
background-position: center;
will-change: transform;
}
.slider__nav {
display: flex;
align-items: center;
gap: 32px;
}
.slider__button,
.slider__counter {
font-family: inherit;
font-size: 11px;
font-weight: 500;
letter-spacing: 0.25em;
text-transform: uppercase;
line-height: 1.6;
}
.slider__button {
padding: 8px 0;
border: 0;
background: none;
color: #000;
cursor: pointer;
transition: color 0.2s ease;
}
.slider__button:hover { color: #8e8e8e; }
.slider__button:focus-visible { outline: 1px solid #000; outline-offset: 2px; }
.slider__counter { color: #8e8e8e; }
</style>
</head>
<body>
<main class="stage">
<div class="slider" id="slider" role="region" aria-roledescription="carousel" aria-label="Photos, drag or use the arrows">
<div class="slider__stage" id="stage"></div>
</div>
<div class="slider__nav">
<button type="button" class="slider__button" id="prev">Previous</button>
<span class="slider__counter" id="counter" aria-live="polite">01 / 05</span>
<button type="button" class="slider__button" id="next">Next</button>
</div>
</main>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/Observer.min.js"></script>
<script>
gsap.registerPlugin(Observer);
/* CONFIG ------------------------------------------------------------------------- */
const CONFIG = {
// Landscape (3:2) photos. Swap in your own image URLs.
images: [
"https://picsum.photos/seed/cadence-1/1600/1067",
"https://picsum.photos/seed/cadence-2/1600/1067",
"https://picsum.photos/seed/cadence-3/1600/1067",
"https://picsum.photos/seed/cadence-4/1600/1067",
"https://picsum.photos/seed/cadence-5/1600/1067",
],
strips: 9,
duration: 1.0,
stagger: 0.05,
ease: "power3.inOut",
overshoot: 8, // % extra travel so strips leave the frame fully
};
// Scroll input: one step per gesture
const WHEEL = {
threshold: 30, // px of wheel travel before a gesture changes the slide
quiet: 220, // ms without wheel events before the next gesture can fire
line: 40, // px per line (wheel deltaMode 1)
};
const SWIPE = { distance: 40, velocity: 300 }; // px / px·s⁻¹ a drag or swipe needs to change the slide
/* Build · a slide is a row of strips sharing one background image ----------------- */
const slider = document.getElementById("slider");
const stage = document.getElementById("stage");
const counter = document.getElementById("counter");
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const images = CONFIG.images;
const count = images.length;
// Preload so the next photo is ready when its strips slide in.
images.forEach((src) => {
const img = new Image();
img.src = src;
});
function makeSlide(src) {
const slide = document.createElement("div");
slide.className = "slide";
const width = 100 / CONFIG.strips;
for (let i = 0; i < CONFIG.strips; i++) {
const strip = document.createElement("div");
strip.className = "strip";
strip.style.left = `${i * width}%`;
strip.style.width = `${width + 0.2}%`; // a hair wider so no seams show
strip.style.backgroundImage = `url("${src}")`;
// Each strip shows its own slice of the full width image.
strip.style.backgroundSize = `${CONFIG.strips * 100}% 100%`;
strip.style.backgroundPosition = `${(i / (CONFIG.strips - 1)) * 100}% 50%`;
slide.appendChild(strip);
}
return slide;
}
let current = 0;
let busy = false;
let currentSlide = makeSlide(images[0]);
stage.appendChild(currentSlide);
function updateCounter() {
counter.textContent = `${String(current + 1).padStart(2, "0")} / ${String(count).padStart(2, "0")}`;
}
/* Transition ------------------------------------------------------------------------ */
function go(direction) {
if (busy) return;
busy = true;
current = (current + direction + count) % count;
updateCounter();
const nextSlide = makeSlide(images[current]);
stage.appendChild(nextSlide);
const outStrips = [...currentSlide.children];
const inStrips = [...nextSlide.children];
function finish() {
currentSlide.remove();
currentSlide = nextSlide;
busy = false;
}
if (reduced) {
gsap.fromTo(nextSlide, { opacity: 0 }, { opacity: 1, duration: 0.4, onComplete: finish });
return;
}
const travel = 100 + CONFIG.overshoot;
// Strips leave up, down, up, down... and the new ones arrive from the other side.
const sign = (i) => (i % 2 === 0 ? -1 : 1) * direction;
const stagger = () => ({ each: CONFIG.stagger, from: direction > 0 ? "start" : "end" });
const timeline = gsap.timeline({ onComplete: finish });
timeline.to(outStrips, {
yPercent: (i) => sign(i) * travel,
duration: CONFIG.duration,
ease: CONFIG.ease,
stagger: stagger(),
}, 0);
timeline.fromTo(
inStrips,
{ yPercent: (i) => -sign(i) * travel },
{ yPercent: 0, duration: CONFIG.duration, ease: CONFIG.ease, stagger: stagger() },
0.08
);
}
/* Input ----------------------------------------------------------------------------- */
document.getElementById("next").addEventListener("click", () => go(1));
document.getElementById("prev").addEventListener("click", () => go(-1));
window.addEventListener("keydown", (event) => {
if (["ArrowRight", "ArrowDown", "PageDown"].includes(event.key)) go(1);
else if (["ArrowLeft", "ArrowUp", "PageUp"].includes(event.key)) go(-1);
else return;
event.preventDefault();
});
// Drag and swipe: horizontal on the slider (left: next); vertical anywhere on the page,
// like scrolling (up: next). The stronger axis decides.
Observer.create({
target: document.querySelector(".stage"),
type: "pointer,touch",
dragMinimum: 20,
onDragEnd: (self) => {
const dx = self.x - self.startX;
const dy = self.y - self.startY;
const horizontal = Math.abs(dx) >= Math.abs(dy);
if (horizontal && !slider.contains(self.event.target)) return; // sideways drags only on the slider
const moved = horizontal ? dx : dy;
const speed = horizontal ? self.velocityX : self.velocityY;
if (Math.abs(moved) > SWIPE.distance || Math.abs(speed) > SWIPE.velocity) go(moved < 0 ? 1 : -1);
},
});
// Wheel and trackpad: one step per gesture (down or right: next; up or left: previous).
let wheelTravel = 0;
let wheelHeld = false; // a step has fired; wait for the gesture to end
let wheelTimer = 0;
window.addEventListener("wheel", (event) => {
event.preventDefault();
clearTimeout(wheelTimer);
wheelTimer = setTimeout(() => {
wheelHeld = false;
wheelTravel = 0;
}, WHEEL.quiet);
if (wheelHeld || busy) return;
const scale = event.deltaMode === 1 ? WHEEL.line : event.deltaMode === 2 ? window.innerHeight : 1;
wheelTravel += (Math.abs(event.deltaY) >= Math.abs(event.deltaX) ? event.deltaY : event.deltaX) * scale;
if (Math.abs(wheelTravel) >= WHEEL.threshold) {
wheelHeld = true;
go(wheelTravel > 0 ? 1 : -1);
}
}, { passive: false });
updateCounter();
</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>
원본 구현 프롬프트
# Slice Slider: a photo slider whose transition cuts the image into sliding vertical strips
**Goal:** build an image slider where, on every change, the current photo splits into vertical strips that slide out up and down alternately while the next photo's strips slide in from the opposite side, staggered from the edge.
## Visual behaviour
1. **Layout.** A 3:2 slider frame (max 1100 px wide, never taller than the viewport allows), centred on a white page, 6 px rounded corners, `overflow: hidden`, light grey `#EFEFEF` behind the photos. Below it, a small row: "Previous", a "01 / 05" counter and "Next" (small uppercase labels, 32 px apart).
2. **Strips.** A slide is 9 vertical strips side by side, each 1/9 of the frame wide (plus 0.2 % so no seams show). Every strip has the same photo as its background, sized to the full frame (`background-size: 900% 100%`) and positioned so its slice lines up (`background-position-x: i / 8 × 100%`). At rest, the strips look like one whole photo.
3. **Going forward (Next).**
- The counter updates immediately.
- The next photo's 9 strips are added on top.
- Current strips slide out vertically by 108 % of their height: even strips (1st, 3rd, …) **up**, odd strips **down**.
- New strips slide in from the opposite side (even strips from below, odd strips from above) to 0, starting 0.08 s after the outgoing ones.
- Both sets: 1.0 s each, `power3.inOut`, staggered 0.05 s per strip starting from the **left** strip.
- When the timeline completes, the old slide is removed. Total ≈ 1.48 s.
4. **Going back (Previous).** The same, mirrored: directions flip (even strips leave down, odd up) and the stagger starts from the **right** strip.
5. **One transition at a time.** Input during a transition is ignored. The slider wraps around (after the last photo comes the first).
## Exact values
| What | Value |
|---|---|
| Strips | 9, width `100 / 9 %` + 0.2 % |
| Travel | 108 % of strip height (100 % + 8 % overshoot), `yPercent` |
| Duration | 1.0 s per strip |
| Ease | `power3.inOut` (ease-in-out quart, ≈ `cubic-bezier(0.76, 0, 0.24, 1)`) |
| Stagger | 0.05 s per strip, from the left going forward, from the right going back |
| Incoming offset | 0.08 s after the outgoing strips start |
| Direction pattern | `sign(i) = (i even ? −1 : 1) × direction`; out to `sign × 108`, in from `−sign × 108` |
| Frame | 3:2, `min(100%, 1100px, (100svh − 240px) × 1.5)`, radius 6 px |
## Input
- **Buttons:** Previous / Next.
- **Keys:** → / ↓ / PageDown = next; ← / ↑ / PageUp = previous (prevent the default scroll).
- **Drag / swipe:** horizontal drags on the slider (left = next) and vertical swipes anywhere on the page (up = next); the stronger axis decides. A drag must move at least 20 px to count, and changes the slide if it travelled more than 40 px or moved faster than 300 px/s. The page has `touch-action: none` so swipes never scroll it.
- **Wheel / trackpad:** one step per gesture. Accumulate wheel delta (dominant axis; 40 px per line in line mode); once it reaches ±30 px, go next (positive: down / right) or previous, then ignore further wheel events until there have been none for 220 ms (so trackpad inertia can't trigger a second step). Ignore the wheel while a transition runs. Listen with `{ passive: false }` and `preventDefault()`.
## Markup
```html
<div class="slider"> <!-- position: relative; aspect-ratio: 3/2; overflow: hidden -->
<div class="slider__stage"> <!-- position: absolute; inset: 0 -->
<div class="slide"> <!-- position: absolute; inset: 0; built in JS -->
<div class="strip" style="left: 0%; width: 11.31%; background-image: url(…); background-size: 900% 100%; background-position: 0% 50%"></div>
… 9 strips
</div>
</div>
</div>
<div class="slider__nav">
<button id="prev">Previous</button> <span id="counter">01 / 05</span> <button id="next">Next</button>
</div>
```
- Strips: `position: absolute; top: 0; height: 100%; will-change: transform`.
- Preload all photos on start so incoming strips are never blank.
## Libraries
- **GSAP 3** for the timeline: `tl.to(outStrips, { yPercent: i => sign(i) * 108, duration: 1, ease: "power3.inOut", stagger: { each: 0.05, from: "start" | "end" } }, 0)` and `tl.fromTo(inStrips, { yPercent: i => -sign(i) * 108 }, { yPercent: 0, …same }, 0.08)`, with `onComplete` removing the old slide.
- **GSAP Observer** (optional) for drag and swipe (`type: "pointer,touch"`, `dragMinimum: 20`, decide in `onDragEnd`).
- **Plain alternative:** Web Animations API: `strip.animate([{ transform: "translateY(0)" }, { transform: "translateY(-108%)" }], { duration: 1000, delay: i * 50, easing: "cubic-bezier(0.76, 0, 0.24, 1)", fill: "forwards" })`, and pointer events for swipes.
## Responsive and reduced motion
- The frame shrinks with the viewport and stays 3:2; the strip count and timing stay the same.
- `prefers-reduced-motion: reduce`: no strips moving; the next slide simply fades in over 0.4 s.
## Acceptance criteria
- At rest the photo looks whole, with no visible seams between strips.
- Next: strips leave alternately up and down in a wave from left to right while the new photo's strips arrive from the opposite sides, and the new photo is whole again after about 1.5 s.
- Previous: the same wave, running from right to left with the directions flipped.
- Rapid clicks, key presses or a long trackpad swipe advance only one slide per transition / gesture.
- The counter always shows the photo that is arriving.
- With reduced motion, slides crossfade.
보관·활용 메모
Cadence에서 제공하는 코드와 프롬프트, 미리보기 영상을 이 사이트에 보관했습니다. 코드는 다운로드한 원본으로 제공하며 실행 화면은 별도로 격리되어 있습니다. 코드의 외부 라이브러리·샘플 이미지 요청은 유지됩니다.
보관일: 2026-10-26 · 프로젝트 적용: 별도 검증 필요
원본 코드·프롬프트·제작자 정보를 함께 보관했습니다. 실제 납품 시 코드와 포함 소재의 이용 조건을 확인하세요.
코드 SHA-256: 2c0e86f28d2c280cda1012ef81d435a05db4ce11b9215c38d54c6ef21e87d4b3