CSS 슬라이더로 만드는 캐러셀 9가지 — 복붙 코드
CSS 슬라이더는 사진이나 카드 여러 장이 화면 안에서 옆으로 또는 위아래로 순서대로 넘어가며 보이게 하는 화면 부품입니다. 스크롤부터 라디오 트릭까지, 자바스크립트 없이 CSS만으로 만드는 아홉 가지 방식을 그대로 복사해서 쓸 수 있게 모았습니다.
01 slider-carousel-css-only-snap-peek-next
02 slider-carousel-css-only-radio-slider-arrows
03 slider-carousel-css-only-snap-dots-target
05 slider-carousel-css-only-snap-vertical
06 slider-carousel-css-only-snap-cover-flow
07 slider-carousel-css-only-stacked-card-swipe
08 slider-carousel-css-only-autoplay-kenburns
09 slider-carousel-css-only-scroll-timeline-progress
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 다음 슬라이드 살짝 보임
- 02 라디오+화살표 슬라이더
- 03 :target 도트 내비
- 04 썸네일 연동 스냅
- 05 세로 스냅
- 06 커버플로우 스냅
- 07 카드 스택 스와이프
- 08 켄번즈 줌 자동재생 히어로
- 09 스크롤 연동 진행 바
아홉 개는 인기순이 아니라 html 슬라이더 만들기에서 실제로 손이 가는 순서입니다. 손가락으로 밀어 넘기는 가장 기본형(01)에서 화살표·점·썸네일로 조작 방법을 바꿔보는 것(02~04)으로 넘어가고, 그다음은 방향과 모양을 바꿔보는 것(05~06), 마지막은 아무것도 안 눌러도 저절로 움직이거나 지금 어디쯤인지 알려주는 것(07~09) 순서입니다. 캐러셀과 함께 쓰기 좋은 짝은 화면 귀퉁이의 위로가기 버튼이고, 슬라이드 묶음 위에 붙어 따라다닐 층이 필요하면 CSS 헤더 고정 응용이 있습니다.
01다음 슬라이드 살짝 보임
다음 장을 살짝 보여준다
카드 사이 여백을 넉넉히 남겨 옆 칸의 슬라이드가 화면 끝에 살짝 걸쳐 보이게 하고, scroll-padding-left로 첫 칸이 화면 왼쪽 끝에 딱 맞춰 멈추게 합니다.

<main class="stage">
<div class="pn is-demo">
<div class="pn__viewport">
<div class="pn__track">
<div class="pn__slide pn__slide--1"><span class="pn__cap">01</span></div>
<div class="pn__slide pn__slide--2"><span class="pn__cap">02</span></div>
<div class="pn__slide pn__slide--3"><span class="pn__cap">03</span></div>
<div class="pn__slide pn__slide--4"><span class="pn__cap">04</span></div>
<div class="pn__slide pn__slide--1" aria-hidden="true"><span class="pn__cap">01</span></div>
<div class="pn__slide pn__slide--2" aria-hidden="true"><span class="pn__cap">02</span></div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
.pn { width: min(360px, 88vw); }
// ── 실제 동작: overflow-x:auto + scroll-snap-type + scroll-padding-left (0줄 JS) ──
// 카드가 뷰포트의 78% 만 차지해 다음 카드가 22% 만큼 미리 보인다.
.pn__viewport {
overflow-x: auto;
scroll-snap-type: x mandatory;
scroll-padding-left: $sp-4;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
}
.pn__track {
display: flex;
width: max-content;
}
.pn__slide {
flex: 0 0 200px;
aspect-ratio: 4 / 3;
scroll-snap-align: start;
margin-right: $sp-3;
border-radius: $r-card;
position: relative;
overflow: hidden;
display: grid;
place-items: end start;
box-sizing: border-box;
&:last-child { margin-right: 0; }
}
.pn__slide--1 { background: linear-gradient(135deg, $color, #d6ddeb); }
.pn__slide--2 { background: linear-gradient(135deg, $stage-orange, #ebdbd6); }
.pn__slide--3 { background: linear-gradient(135deg, #c2ae82, $stage-yellow); }
.pn__slide--4 { background: linear-gradient(135deg, $subject-ink, #4a3f5c); }
.pn__cap {
position: relative; z-index: 1;
margin: $sp-4;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 700;
font-size: clamp(12px, 3.2vw, 14px);
line-height: 1;
font-family: inherit;
}
// ── 데모 루프: 클릭·드래그 전까지 한 칸씩 스스로 넘어간다 (진짜 스크롤은 그대로 살아있다) ──
// 트랙 끝에는 1번의 복제본(aria-hidden)이 한 장 더 — 그곳까지 정상 속도로 가고
// 100%→0% 점프는 복제본이 1번과 같아 안 보인다 (끝→처음 텔레포트 금지, 사용자 2026-09-11)
.pn.is-demo .pn__track { animation: pn-loop $dur-loop $easing infinite; }
@keyframes pn-loop {
0%, 8% { transform: translateX(0px); }
23%, 31% { transform: translateX(-212px); }
46%, 54% { transform: translateX(-424px); }
69%, 77% { transform: translateX(-636px); }
92%, 100% { transform: translateX(-848px); }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.pn.is-demo .pn__track { animation: none !important; }
.pn__viewport { scroll-behavior: auto; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pn {
width: min(360px, 88vw);
}
.pn__viewport {
overflow-x: auto;
scroll-snap-type: x mandatory;
scroll-padding-left: 16px;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.pn__viewport::-webkit-scrollbar {
display: none;
}
.pn__track {
display: flex;
width: max-content;
}
.pn__slide {
flex: 0 0 200px;
aspect-ratio: 4/3;
scroll-snap-align: start;
margin-right: 12px;
border-radius: 16px;
position: relative;
overflow: hidden;
display: grid;
place-items: end start;
box-sizing: border-box;
}
.pn__slide:last-child {
margin-right: 0;
}
.pn__slide--1 {
background: linear-gradient(135deg, #2f6df6, #d6ddeb);
}
.pn__slide--2 {
background: linear-gradient(135deg, #ff4d1f, #ebdbd6);
}
.pn__slide--3 {
background: linear-gradient(135deg, #c2ae82, #ffd23f);
}
.pn__slide--4 {
background: linear-gradient(135deg, #17141a, #4a3f5c);
}
.pn__cap {
position: relative;
z-index: 1;
margin: 16px;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 700;
font-size: clamp(12px, 3.2vw, 14px);
line-height: 1;
font-family: inherit;
}
.pn.is-demo .pn__track {
animation: pn-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes pn-loop {
0%, 8% {
transform: translateX(0px);
}
23%, 31% {
transform: translateX(-212px);
}
46%, 54% {
transform: translateX(-424px);
}
69%, 77% {
transform: translateX(-636px);
}
92%, 100% {
transform: translateX(-848px);
}
}
@media (prefers-reduced-motion: reduce) {
.pn.is-demo .pn__track {
animation: none !important;
}
.pn__viewport {
scroll-behavior: auto;
}
}// 실제 동작은 overflow-x:auto + scroll-snap-type + scroll-padding 만으로 끝난다(0줄).
// 이 3줄은 미리보기 자동 재생만 멈춘다 — 진짜 드래그·스크롤은 그대로 살아있다.
document.querySelector('.pn__viewport').addEventListener('pointerdown', function () {
document.querySelector('.pn').classList.remove('is-demo');
});import type { CSSProperties } from "react";
import styles from "./PeekNext.module.scss";
export type PeekNextProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function PeekNext({ duration = 300, easing = "cubic-bezier(.2,.8,.2,1)", color = "#2f6df6" }: PeekNextProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.pn} style={vars}>
{/* 실제 동작은 overflow-x:auto + scroll-snap-type + scroll-padding-left 만으로 끝난다 — 0줄 JS */}
<div className={styles.viewport}>
<div className={styles.track}>
{[1, 2, 3, 4].map((n) => (
<div key={n} className={`${styles.slide} ${styles["slide" + n]}`}>
<span className={styles.cap}>{String(n).padStart(2, "0")}</span>
</div>
))}
</div>
</div>
</div>
);
}01 · 다음 슬라이드 살짝 보임 — 측정 누적면적 37.5% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
쇼핑몰 상품 목록에
쇼핑몰 상품 목록처럼 "더 있다"는 걸 보여주고 싶을 때 씁니다.
.pn__viewport {
overflow-x: auto;
scroll-snap-type: x mandatory;
scroll-padding-left: 16px;
}
.pn__track { display: flex; width: max-content; }
.pn__slide {
flex: 0 0 200px;
scroll-snap-align: start;
margin-right: 12px;
}
02라디오+화살표 슬라이더
숨긴 라디오와 화살표 라벨
숨긴 라디오 버튼 세 개와 화살표 라벨만으로 슬라이드를 한 칸씩 넘기는, 스크롤 없이도 되는 가장 오래된 CSS 캐러셀 방식입니다.

<main class="stage">
<div class="ra is-demo">
<input type="radio" name="ra-tab" id="ra-1" class="ra__radio" checked>
<input type="radio" name="ra-tab" id="ra-2" class="ra__radio">
<input type="radio" name="ra-tab" id="ra-3" class="ra__radio">
<div class="ra__viewport">
<div class="ra__track">
<div class="ra__slide ra__slide--1">
<label class="ra__next" for="ra-2" aria-label="다음 슬라이드">›</label>
</div>
<div class="ra__slide ra__slide--2">
<label class="ra__prev" for="ra-1" aria-label="이전 슬라이드">‹</label>
<label class="ra__next" for="ra-3" aria-label="다음 슬라이드">›</label>
</div>
<div class="ra__slide ra__slide--3">
<label class="ra__prev" for="ra-2" aria-label="이전 슬라이드">‹</label>
</div>
<div class="ra__slide ra__slide--1" aria-hidden="true">
<span class="ra__next" aria-hidden="true">›</span>
</div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base;
$easing: $ease-spring;
$color: $stage-orange;
@include stage($stage-yellow);
.ra { width: min(360px, 88vw); }
.ra__radio { position: absolute; opacity: 0; pointer-events: none; }
// ── 실제 동작: 고른 라디오에 맞춰 트랙 전체가 밀린다 (형제결합자 + :checked, 0줄 JS) ──
// 트랙 끝에는 1번의 복제본(aria-hidden)이 한 장 더 — 예고편 루프의 끝→처음 점프를 숨긴다 (텔레포트 금지)
.ra__viewport { overflow: hidden; border-radius: $r-card; }
.ra__track {
display: flex;
width: 400%;
transition: transform $duration $easing;
}
#ra-1:checked ~ .ra__viewport .ra__track { transform: translateX(0%); }
#ra-2:checked ~ .ra__viewport .ra__track { transform: translateX(-25%); }
#ra-3:checked ~ .ra__viewport .ra__track { transform: translateX(-50%); }
.ra__slide {
flex: 0 0 25%;
aspect-ratio: 16 / 9;
position: relative;
display: grid;
}
.ra__slide--1 { background: linear-gradient(135deg, $subject-ink, #4a3f5c); }
.ra__slide--2 { background: linear-gradient(135deg, $color, #ebdbd6); }
.ra__slide--3 { background: linear-gradient(135deg, $subject-blue, #d6ddeb); }
.ra__prev, .ra__next {
position: absolute; top: 50%; transform: translateY(-50%);
width: $sp-8; height: $sp-8;
display: grid; place-items: center;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 700;
font-size: 20px;
line-height: 1;
font-family: inherit;
cursor: pointer;
box-shadow: $shadow-press;
}
.ra__prev { left: $sp-3; }
.ra__next { right: $sp-3; }
// ── 데모 루프: 클릭 전까지 라디오가 스스로 1→2→3→1 순으로 넘어간다 (checked 는 1번 그대로) ──
// 마지막 칸(-75%)은 1번의 복제본 — 100%→0% 점프가 복제본=1번이라 안 보인다
.ra.is-demo .ra__viewport .ra__track { animation: ra-loop $dur-loop $easing infinite; }
@keyframes ra-loop {
0%, 8% { transform: translateX(0%); }
31%, 39% { transform: translateX(-25%); }
62%, 70% { transform: translateX(-50%); }
92%, 100% { transform: translateX(-75%); }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.ra__track { transition: none; }
.ra.is-demo .ra__viewport .ra__track { animation: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ra {
width: min(360px, 88vw);
}
.ra__radio {
position: absolute;
opacity: 0;
pointer-events: none;
}
.ra__viewport {
overflow: hidden;
border-radius: 16px;
}
.ra__track {
display: flex;
width: 400%;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
#ra-1:checked ~ .ra__viewport .ra__track {
transform: translateX(0%);
}
#ra-2:checked ~ .ra__viewport .ra__track {
transform: translateX(-25%);
}
#ra-3:checked ~ .ra__viewport .ra__track {
transform: translateX(-50%);
}
.ra__slide {
flex: 0 0 25%;
aspect-ratio: 16/9;
position: relative;
display: grid;
}
.ra__slide--1 {
background: linear-gradient(135deg, #17141a, #4a3f5c);
}
.ra__slide--2 {
background: linear-gradient(135deg, #ff4d1f, #ebdbd6);
}
.ra__slide--3 {
background: linear-gradient(135deg, #2f6df6, #d6ddeb);
}
.ra__prev, .ra__next {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 32px;
height: 32px;
display: grid;
place-items: center;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 700;
font-size: 20px;
line-height: 1;
font-family: inherit;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.ra__prev {
left: 12px;
}
.ra__next {
right: 12px;
}
.ra.is-demo .ra__viewport .ra__track {
animation: ra-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes ra-loop {
0%, 8% {
transform: translateX(0%);
}
31%, 39% {
transform: translateX(-25%);
}
62%, 70% {
transform: translateX(-50%);
}
92%, 100% {
transform: translateX(-75%);
}
}
@media (prefers-reduced-motion: reduce) {
.ra__track {
transition: none;
}
.ra.is-demo .ra__viewport .ra__track {
animation: none !important;
}
}// 실제 동작은 라디오 :checked + 형제결합자로 트랙을 미는 것만으로 끝난다(0줄).
// 이 3줄은 미리보기 자동 재생만 멈춘다.
document.querySelectorAll('.ra__radio, .ra__prev, .ra__next').forEach(function (el) {
el.addEventListener('pointerdown', function () { document.querySelector('.ra').classList.remove('is-demo'); });
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./RadioArrows.module.scss";
export type RadioArrowsProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string; "--pos"?: string };
const N = 3;
export default function RadioArrows({ duration = 300, easing = "cubic-bezier(.2,.8,.2,1)", color = "#ff4d1f" }: RadioArrowsProps) {
const [active, setActive] = useState(0);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color, "--pos": `${-active * (100 / N)}%` };
return (
<div className={styles.ra} style={vars}>
{/* 원본은 라디오 :checked + 형제결합자로 트랙을 민다(0줄 JS). 리액트 판은 같은 이동을 useState 로 대신한다 */}
<div className={styles.viewport}>
<div className={styles.track}>
{[1, 2, 3].map((n) => (
<div key={n} className={`${styles.slide} ${styles["slide" + n]}`} />
))}
</div>
</div>
<button type="button" className={styles.prev} aria-label="이전 슬라이드" onClick={() => setActive((active - 1 + N) % N)}>‹</button>
<button type="button" className={styles.next} aria-label="다음 슬라이드" onClick={() => setActive((active + 1) % N)}>›</button>
</div>
);
}02 · 라디오+화살표 슬라이더 — 측정 누적면적 50.374% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
손으로만 넘기는 자리
배너나 공지 카드처럼 손으로만 넘기게 하고 싶을 때 씁니다.
.ra__track {
display: flex;
width: 300%;
transition: transform .3s;
}
#ra-1:checked ~ .ra__viewport .ra__track { transform: translateX(0%); }
#ra-2:checked ~ .ra__viewport .ra__track { transform: translateX(-33.3333%); }
#ra-3:checked ~ .ra__viewport .ra__track { transform: translateX(-66.6667%); }
03:target 도트 내비
점을 누르면 주소가 바뀐다
점을 누르면 실제 링크처럼 주소창의 #이 바뀌고, 브라우저가 그 슬라이드를 스크롤 상자 안에서 화면에 맞춰 이동시킵니다. 지금 가리키는 슬라이드는 :target 선택자로 판별해 그 슬라이드에 해당하는 점만 칠합니다.

<main class="stage">
<div class="dt is-demo">
<div class="dt__viewport">
<div class="dt__track">
<section id="dt-s1" class="dt__slide dt__slide--1"></section>
<section id="dt-s2" class="dt__slide dt__slide--2"></section>
<section id="dt-s3" class="dt__slide dt__slide--3"></section>
<section class="dt__slide dt__slide--1" aria-hidden="true"></section>
</div>
</div>
<nav class="dt__dots">
<a href="#dt-s1" class="dt__dot dt__dot--1" aria-label="슬라이드 1"></a>
<a href="#dt-s2" class="dt__dot dt__dot--2" aria-label="슬라이드 2"></a>
<a href="#dt-s3" class="dt__dot dt__dot--3" aria-label="슬라이드 3"></a>
</nav>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base;
$easing: $ease-spring;
$color: $stage-yellow;
@include stage($stage-orange);
.dt { width: min(300px, 80vw); }
// ── 실제 동작: <a href="#id"> 로 진짜 앵커 이동 + :target 으로 슬라이드가 스크롤 상자 안에서 이동 (0줄 JS) ──
.dt__viewport {
overflow-x: auto;
scroll-snap-type: x mandatory;
border-radius: $r-card;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
}
.dt__track { display: flex; width: max-content; }
.dt__slide {
flex: 0 0 min(300px, 80vw);
aspect-ratio: 16 / 9;
scroll-snap-align: start;
scroll-margin-left: 0;
}
.dt__slide--1 { background: linear-gradient(135deg, $subject-ink, #4a3f5c); }
.dt__slide--2 { background: linear-gradient(135deg, $subject-blue, #d6ddeb); }
.dt__slide--3 { background: linear-gradient(135deg, $color, #ebe7d6); }
.dt__dots {
display: flex;
justify-content: center;
gap: $sp-2;
margin-top: $sp-3;
}
.dt__dot {
width: $sp-2; height: $sp-2;
border-radius: 50%;
background: rgba(23, 20, 26, .28);
transition: transform $duration $easing, background $duration $easing;
}
// :target 은 링크(dot)가 아니라 가리킨 슬라이드(section)에 붙는다 — :has() 로 같은 편의 점을 역으로 찾아 칠한다
.dt:has(#dt-s1:target) .dt__dot--1,
.dt:has(#dt-s2:target) .dt__dot--2,
.dt:has(#dt-s3:target) .dt__dot--3 {
background: $color;
transform: scale(1.4);
}
// ── 데모 루프: 진짜 :target 상태는 그대로 두고, 트랙·점만 미리보기로 스스로 순환한다 ──
// 트랙 끝에는 1번의 복제본(aria-hidden)이 한 장 더 — 그곳까지 정상 속도로 가고 100%→0% 점프는
// 복제본=1번이라 안 보인다 (끝→처음 텔레포트 금지). % 는 트랙(max-content=슬라이드 4장) 기준이라
// 한 칸은 정확히 -25% — 슬라이드 폭이 vw 단위라 창이 좁아져도 어긋나지 않는다
.dt.is-demo .dt__track { animation: dt-track $dur-loop $easing infinite; }
.dt.is-demo .dt__dot--1 { animation: dt-dot-1 $dur-loop $easing infinite; }
.dt.is-demo .dt__dot--2 { animation: dt-dot-2 $dur-loop $easing infinite; }
.dt.is-demo .dt__dot--3 { animation: dt-dot-3 $dur-loop $easing infinite; }
@keyframes dt-track {
0%, 8% { transform: translateX(0); }
31%, 39% { transform: translateX(-25%); }
62%, 70% { transform: translateX(-50%); }
92%, 100% { transform: translateX(-75%); }
}
// 점 1번은 루프 끝(92~100%, 복제본=1번이 보이는 구간)에 다시 켜진다 — 100% 와 0% 의 상태가 같아야
// 루프 경계에서 점이 순간이동으로 바뀌지 않는다
@keyframes dt-dot-1 { 0%, 4% { background: $color; transform: scale(1.4); } 29%, 88% { background: rgba(23,20,26,.28); transform: scale(1); } 92%, 100% { background: $color; transform: scale(1.4); } }
@keyframes dt-dot-2 { 0%, 29% { background: rgba(23,20,26,.28); transform: scale(1); } 33%, 62% { background: $color; transform: scale(1.4); } 66%, 100% { background: rgba(23,20,26,.28); transform: scale(1); } }
@keyframes dt-dot-3 { 0%, 62% { background: rgba(23,20,26,.28); transform: scale(1); } 66%, 96% { background: $color; transform: scale(1.4); } 100% { background: rgba(23,20,26,.28); transform: scale(1); } }
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.dt.is-demo .dt__track,
.dt.is-demo .dt__dot--1,
.dt.is-demo .dt__dot--2,
.dt.is-demo .dt__dot--3 { animation: none !important; }
.dt__viewport { scroll-behavior: auto; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.dt {
width: min(300px, 80vw);
}
.dt__viewport {
overflow-x: auto;
scroll-snap-type: x mandatory;
border-radius: 16px;
scrollbar-width: none;
}
.dt__viewport::-webkit-scrollbar {
display: none;
}
.dt__track {
display: flex;
width: max-content;
}
.dt__slide {
flex: 0 0 min(300px, 80vw);
aspect-ratio: 16/9;
scroll-snap-align: start;
scroll-margin-left: 0;
}
.dt__slide--1 {
background: linear-gradient(135deg, #17141a, #4a3f5c);
}
.dt__slide--2 {
background: linear-gradient(135deg, #2f6df6, #d6ddeb);
}
.dt__slide--3 {
background: linear-gradient(135deg, #ffd23f, #ebe7d6);
}
.dt__dots {
display: flex;
justify-content: center;
gap: 8px;
margin-top: 12px;
}
.dt__dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: rgba(23, 20, 26, 0.28);
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1), background 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.dt:has(#dt-s1:target) .dt__dot--1,
.dt:has(#dt-s2:target) .dt__dot--2,
.dt:has(#dt-s3:target) .dt__dot--3 {
background: #ffd23f;
transform: scale(1.4);
}
.dt.is-demo .dt__track {
animation: dt-track 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.dt.is-demo .dt__dot--1 {
animation: dt-dot-1 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.dt.is-demo .dt__dot--2 {
animation: dt-dot-2 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.dt.is-demo .dt__dot--3 {
animation: dt-dot-3 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes dt-track {
0%, 8% {
transform: translateX(0);
}
31%, 39% {
transform: translateX(-25%);
}
62%, 70% {
transform: translateX(-50%);
}
92%, 100% {
transform: translateX(-75%);
}
}
@keyframes dt-dot-1 {
0%, 4% {
background: #ffd23f;
transform: scale(1.4);
}
29%, 88% {
background: rgba(23, 20, 26, 0.28);
transform: scale(1);
}
92%, 100% {
background: #ffd23f;
transform: scale(1.4);
}
}
@keyframes dt-dot-2 {
0%, 29% {
background: rgba(23, 20, 26, 0.28);
transform: scale(1);
}
33%, 62% {
background: #ffd23f;
transform: scale(1.4);
}
66%, 100% {
background: rgba(23, 20, 26, 0.28);
transform: scale(1);
}
}
@keyframes dt-dot-3 {
0%, 62% {
background: rgba(23, 20, 26, 0.28);
transform: scale(1);
}
66%, 96% {
background: #ffd23f;
transform: scale(1.4);
}
100% {
background: rgba(23, 20, 26, 0.28);
transform: scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.dt.is-demo .dt__track,
.dt.is-demo .dt__dot--1,
.dt.is-demo .dt__dot--2,
.dt.is-demo .dt__dot--3 {
animation: none !important;
}
.dt__viewport {
scroll-behavior: auto;
}
}// 실제 동작은 <a href="#id"> + :target 만으로 끝난다(0줄) — 점을 누르면 주소창 #이 바뀌고
// 브라우저가 그 슬라이드를 스크롤 상자 안에서 화면에 맞춰 옮긴다.
// 이 3줄은 미리보기 자동 재생만 멈춘다.
document.querySelector('.dt__viewport').addEventListener('pointerdown', function () {
document.querySelector('.dt').classList.remove('is-demo');
});import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./DotsTarget.module.scss";
export type DotsTargetProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type TrackEl = { children: { item(i: number): { scrollIntoView(opts: { behavior: string; inline: string }): void } | null } } | null;
export default function DotsTarget({ duration = 300, easing = "cubic-bezier(.2,.8,.2,1)", color = "#ffd23f" }: DotsTargetProps) {
const [active, setActive] = useState(0);
const track = useRef<TrackEl>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const go = (i: number) => {
setActive(i);
track.current?.children.item(i)?.scrollIntoView({ behavior: "smooth", inline: "start" });
};
return (
<div className={styles.dt} style={vars}>
{/* 원본은 <a href="#id"> + :target 만으로 끝난다(0줄 JS). 컴포넌트는 아이디가 겹치지 않도록
scrollIntoView 로 같은 동작을 낸다 — 실제로 슬라이드가 스크롤 상자 안에서 움직인다 */}
<div className={styles.viewport} ref={track as never}>
<div className={styles.track}>
{[1, 2, 3].map((n) => (
<div key={n} className={`${styles.slide} ${styles["slide" + n]}`} />
))}
</div>
</div>
<nav className={styles.dots}>
{[0, 1, 2].map((i) => (
<button
key={i}
type="button"
aria-label={`슬라이드 ${i + 1}`}
className={`${styles.dot} ${i === active ? styles.isActive : ""}`}
onClick={() => go(i)}
/>
))}
</nav>
</div>
);
}03 · :target 도트 내비 — 측정 누적면적 35.13% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
뒤로가기가 필요한 갤러리에
뒤로가기를 누르면 이전 슬라이드로 돌아가야 하는 갤러리에 알맞습니다.
.dt__viewport { overflow-x: auto; scroll-snap-type: x mandatory; }
.dt__slide { scroll-snap-align: start; }
.dt:has(#dt-s1:target) .dt__dot--1,
.dt:has(#dt-s2:target) .dt__dot--2,
.dt:has(#dt-s3:target) .dt__dot--3 {
background: var(--color);
transform: scale(1.4);
}
04썸네일 연동 스냅
썸네일이 진짜 이미지다
큰 사진 아래 진짜 썸네일 이미지 줄이 따로 있고, 각 썸네일은 대응하는 슬라이드로 가는 앵커라 :target으로 지금 보는 사진의 썸네일에만 테두리가 켜집니다.
<main class="stage">
<div class="tm is-demo">
<div class="tm__viewport">
<div class="tm__track">
<section id="tm-s1" class="tm__slide tm__slide--1"></section>
<section id="tm-s2" class="tm__slide tm__slide--2"></section>
<section id="tm-s3" class="tm__slide tm__slide--3"></section>
<section class="tm__slide tm__slide--1" aria-hidden="true"></section>
</div>
</div>
<nav class="tm__thumbs">
<a href="#tm-s1" class="tm__thumb tm__thumb--1" aria-label="슬라이드 1 썸네일"></a>
<a href="#tm-s2" class="tm__thumb tm__thumb--2" aria-label="슬라이드 2 썸네일"></a>
<a href="#tm-s3" class="tm__thumb tm__thumb--3" aria-label="슬라이드 3 썸네일"></a>
</nav>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
.tm { width: min(260px, 70vw); }
// ── 실제 동작: 큰 슬라이드 + 진짜 썸네일 이미지 줄, 둘 다 <a href="#id"> + :target (0줄 JS) ──
.tm__viewport {
overflow-x: auto;
scroll-snap-type: x mandatory;
border-radius: $r-card;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
}
.tm__track { display: flex; width: max-content; }
.tm__slide {
flex: 0 0 min(260px, 70vw);
aspect-ratio: 16 / 9;
scroll-snap-align: start;
}
.tm__slide--1 { background: linear-gradient(135deg, $color, #d6ddeb); }
.tm__slide--2 { background: linear-gradient(135deg, $stage-orange, #ebdbd6); }
.tm__slide--3 { background: linear-gradient(135deg, #c2ae82, $stage-yellow); }
.tm__thumbs {
display: flex;
justify-content: center;
gap: $sp-2;
margin-top: $sp-3;
}
.tm__thumb {
flex: 0 0 28px;
aspect-ratio: 1 / 1;
box-sizing: border-box;
border-radius: $r-sm;
border: 3px solid transparent;
transition: border-color $duration $easing;
}
.tm__thumb--1 { background: linear-gradient(135deg, $color, #d6ddeb); }
.tm__thumb--2 { background: linear-gradient(135deg, $stage-orange, #ebdbd6); }
.tm__thumb--3 { background: linear-gradient(135deg, #c2ae82, $stage-yellow); }
// 각 슬라이드가 :target 이면 :has() 로 같은 색의 썸네일에만 테두리를 켠다
.tm:has(#tm-s1:target) .tm__thumb--1,
.tm:has(#tm-s2:target) .tm__thumb--2,
.tm:has(#tm-s3:target) .tm__thumb--3 {
border-color: $subject-ink;
}
// ── 데모 루프: 큰 슬라이드와 썸네일 테두리가 미리보기에서 함께 순환한다 ──
// 트랙 끝에는 1번의 복제본(aria-hidden)이 한 장 더 — 그곳까지 정상 속도로 가고 100%→0% 점프는
// 복제본=1번이라 안 보인다 (텔레포트 금지). % 는 트랙(max-content=슬라이드 4장) 기준이라 한 칸 = -25%
.tm.is-demo .tm__track { animation: tm-track $dur-loop $easing infinite; }
.tm.is-demo .tm__thumb--1 { animation: tm-thumb-1 $dur-loop $easing infinite; }
.tm.is-demo .tm__thumb--2 { animation: tm-thumb-2 $dur-loop $easing infinite; }
.tm.is-demo .tm__thumb--3 { animation: tm-thumb-3 $dur-loop $easing infinite; }
@keyframes tm-track {
0%, 8% { transform: translateX(0); }
31%, 39% { transform: translateX(-25%); }
62%, 70% { transform: translateX(-50%); }
92%, 100% { transform: translateX(-75%); }
}
// 썸네일 1번도 루프 끝(92~100%, 복제본=1번이 보이는 구간)에 다시 켜진다 — 경계에서 테두리가 순간이동하지 않는다
@keyframes tm-thumb-1 { 0%, 4% { border-color: $subject-ink; } 29%, 88% { border-color: transparent; } 92%, 100% { border-color: $subject-ink; } }
@keyframes tm-thumb-2 { 0%, 29% { border-color: transparent; } 33%, 62% { border-color: $subject-ink; } 66%, 100% { border-color: transparent; } }
@keyframes tm-thumb-3 { 0%, 62% { border-color: transparent; } 66%, 96% { border-color: $subject-ink; } 100% { border-color: transparent; } }
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.tm.is-demo .tm__track,
.tm.is-demo .tm__thumb--1,
.tm.is-demo .tm__thumb--2,
.tm.is-demo .tm__thumb--3 { animation: none !important; }
.tm__viewport { scroll-behavior: auto; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.tm {
width: min(260px, 70vw);
}
.tm__viewport {
overflow-x: auto;
scroll-snap-type: x mandatory;
border-radius: 16px;
scrollbar-width: none;
}
.tm__viewport::-webkit-scrollbar {
display: none;
}
.tm__track {
display: flex;
width: max-content;
}
.tm__slide {
flex: 0 0 min(260px, 70vw);
aspect-ratio: 16/9;
scroll-snap-align: start;
}
.tm__slide--1 {
background: linear-gradient(135deg, #2f6df6, #d6ddeb);
}
.tm__slide--2 {
background: linear-gradient(135deg, #ff4d1f, #ebdbd6);
}
.tm__slide--3 {
background: linear-gradient(135deg, #c2ae82, #ffd23f);
}
.tm__thumbs {
display: flex;
justify-content: center;
gap: 8px;
margin-top: 12px;
}
.tm__thumb {
flex: 0 0 28px;
aspect-ratio: 1/1;
box-sizing: border-box;
border-radius: 8px;
border: 3px solid transparent;
transition: border-color 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.tm__thumb--1 {
background: linear-gradient(135deg, #2f6df6, #d6ddeb);
}
.tm__thumb--2 {
background: linear-gradient(135deg, #ff4d1f, #ebdbd6);
}
.tm__thumb--3 {
background: linear-gradient(135deg, #c2ae82, #ffd23f);
}
.tm:has(#tm-s1:target) .tm__thumb--1,
.tm:has(#tm-s2:target) .tm__thumb--2,
.tm:has(#tm-s3:target) .tm__thumb--3 {
border-color: #17141a;
}
.tm.is-demo .tm__track {
animation: tm-track 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.tm.is-demo .tm__thumb--1 {
animation: tm-thumb-1 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.tm.is-demo .tm__thumb--2 {
animation: tm-thumb-2 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.tm.is-demo .tm__thumb--3 {
animation: tm-thumb-3 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes tm-track {
0%, 8% {
transform: translateX(0);
}
31%, 39% {
transform: translateX(-25%);
}
62%, 70% {
transform: translateX(-50%);
}
92%, 100% {
transform: translateX(-75%);
}
}
@keyframes tm-thumb-1 {
0%, 4% {
border-color: #17141a;
}
29%, 88% {
border-color: transparent;
}
92%, 100% {
border-color: #17141a;
}
}
@keyframes tm-thumb-2 {
0%, 29% {
border-color: transparent;
}
33%, 62% {
border-color: #17141a;
}
66%, 100% {
border-color: transparent;
}
}
@keyframes tm-thumb-3 {
0%, 62% {
border-color: transparent;
}
66%, 96% {
border-color: #17141a;
}
100% {
border-color: transparent;
}
}
@media (prefers-reduced-motion: reduce) {
.tm.is-demo .tm__track,
.tm.is-demo .tm__thumb--1,
.tm.is-demo .tm__thumb--2,
.tm.is-demo .tm__thumb--3 {
animation: none !important;
}
.tm__viewport {
scroll-behavior: auto;
}
}// 실제 동작은 큰 슬라이드·작은 썸네일 둘 다 앵커 링크 + :target 만으로 끝난다(0줄 JS).
// 이 3줄은 미리보기 자동 재생만 멈춘다.
document.querySelector('.tm__viewport').addEventListener('pointerdown', function () {
document.querySelector('.tm').classList.remove('is-demo');
});import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ThumbnailSnap.module.scss";
export type ThumbnailSnapProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type TrackEl = { children: { item(i: number): { scrollIntoView(opts: { behavior: string; inline: string }): void } | null } } | null;
export default function ThumbnailSnap({ duration = 300, easing = "cubic-bezier(.2,.8,.2,1)", color = "#2f6df6" }: ThumbnailSnapProps) {
const [active, setActive] = useState(0);
const track = useRef<TrackEl>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const go = (i: number) => {
setActive(i);
track.current?.children.item(i)?.scrollIntoView({ behavior: "smooth", inline: "start" });
};
return (
<div className={styles.tm} style={vars}>
{/* 원본은 큰 슬라이드·썸네일 둘 다 <a href="#id"> + :target 만으로 끝난다(0줄 JS).
컴포넌트는 아이디 충돌을 피하려 scrollIntoView 로 같은 동작을 낸다 */}
<div className={styles.viewport} ref={track as never}>
<div className={styles.track}>
{[1, 2, 3].map((n) => (
<div key={n} className={`${styles.slide} ${styles["slide" + n]}`} />
))}
</div>
</div>
<nav className={styles.thumbs}>
{[0, 1, 2].map((i) => (
<button
key={i}
type="button"
aria-label={`슬라이드 ${i + 1} 썸네일`}
className={`${styles.thumb} ${styles["thumb" + (i + 1)]} ${i === active ? styles.isActive : ""}`}
onClick={() => go(i)}
/>
))}
</nav>
</div>
);
}04 · 썸네일 연동 스냅 — 측정 누적면적 26.853% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
제품 상세 화면에
제품 상세 이미지처럼 작은 미리보기로 바로 건너뛰게 하고 싶을 때 씁니다.
.tm__thumb {
border: 3px solid transparent;
transition: border-color .3s;
}
.tm:has(#tm-s1:target) .tm__thumb--1,
.tm:has(#tm-s2:target) .tm__thumb--2,
.tm:has(#tm-s3:target) .tm__thumb--3 {
border-color: #17141a;
}
05세로 스냅
스크롤 축을 세로로
가로가 아니라 세로로 스크롤하며 화면 하나에 슬라이드 하나씩 딱 맞춰 멈추는, 휴대폰 스토리 화면과 같은 방향의 캐러셀입니다.

<main class="stage">
<div class="vs is-demo">
<div class="vs__viewport">
<div class="vs__track">
<section class="vs__slide vs__slide--1"><span class="vs__cap">01</span></section>
<section class="vs__slide vs__slide--2"><span class="vs__cap">02</span></section>
<section class="vs__slide vs__slide--3"><span class="vs__cap">03</span></section>
<section class="vs__slide vs__slide--1" aria-hidden="true"><span class="vs__cap">01</span></section>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base;
$easing: $ease-spring;
$color: $stage-orange;
@include stage($stage-ink);
.vs { width: min(180px, 50vw); height: 150px; border-radius: $r-card; overflow: hidden; }
// ── 실제 동작: 가로가 아니라 세로로 overflow-y:auto + scroll-snap-type: y (0줄 JS) ──
.vs__viewport {
height: 100%;
overflow-y: auto;
scroll-snap-type: y mandatory;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
}
.vs__track { display: flex; flex-direction: column; height: max-content; }
.vs__slide {
flex: 0 0 150px;
width: 100%;
scroll-snap-align: start;
display: grid; place-items: end start;
box-sizing: border-box;
}
.vs__slide--1 { background: linear-gradient(160deg, $subject-blue, #d6ddeb); }
.vs__slide--2 { background: linear-gradient(160deg, $color, #ebdbd6); }
.vs__slide--3 { background: linear-gradient(160deg, #c2ae82, $stage-yellow); }
.vs__cap {
margin: $sp-3;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 700;
font-size: 13px;
line-height: 1;
font-family: inherit;
}
// ── 데모 루프: 클릭·드래그 전까지 한 칸씩 스스로 아래로 넘어간다 ──
// 트랙 끝에는 1번의 복제본(aria-hidden)이 한 장 더 — 그곳까지 정상 속도로 가고
// 100%→0% 점프는 복제본이 1번과 같아 안 보인다 (끝→처음 텔레포트 금지)
.vs.is-demo .vs__track { animation: vs-loop $dur-loop $easing infinite; }
@keyframes vs-loop {
0%, 8% { transform: translateY(0px); }
31%, 39% { transform: translateY(-150px); }
62%, 70% { transform: translateY(-300px); }
92%, 100% { transform: translateY(-450px); }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.vs.is-demo .vs__track { animation: none !important; }
.vs__viewport { scroll-behavior: auto; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vs {
width: min(180px, 50vw);
height: 150px;
border-radius: 16px;
overflow: hidden;
}
.vs__viewport {
height: 100%;
overflow-y: auto;
scroll-snap-type: y mandatory;
scrollbar-width: none;
}
.vs__viewport::-webkit-scrollbar {
display: none;
}
.vs__track {
display: flex;
flex-direction: column;
height: max-content;
}
.vs__slide {
flex: 0 0 150px;
width: 100%;
scroll-snap-align: start;
display: grid;
place-items: end start;
box-sizing: border-box;
}
.vs__slide--1 {
background: linear-gradient(160deg, #2f6df6, #d6ddeb);
}
.vs__slide--2 {
background: linear-gradient(160deg, #ff4d1f, #ebdbd6);
}
.vs__slide--3 {
background: linear-gradient(160deg, #c2ae82, #ffd23f);
}
.vs__cap {
margin: 12px;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 700;
font-size: 13px;
line-height: 1;
font-family: inherit;
}
.vs.is-demo .vs__track {
animation: vs-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes vs-loop {
0%, 8% {
transform: translateY(0px);
}
31%, 39% {
transform: translateY(-150px);
}
62%, 70% {
transform: translateY(-300px);
}
92%, 100% {
transform: translateY(-450px);
}
}
@media (prefers-reduced-motion: reduce) {
.vs.is-demo .vs__track {
animation: none !important;
}
.vs__viewport {
scroll-behavior: auto;
}
}// 실제 동작은 overflow-y:auto + scroll-snap-type: y 만으로 끝난다(0줄).
// 이 3줄은 미리보기 자동 재생만 멈춘다.
document.querySelector('.vs__viewport').addEventListener('pointerdown', function () {
document.querySelector('.vs').classList.remove('is-demo');
});import type { CSSProperties } from "react";
import styles from "./VerticalSnap.module.scss";
export type VerticalSnapProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function VerticalSnap({ duration = 300, easing = "cubic-bezier(.2,.8,.2,1)", color = "#ff4d1f" }: VerticalSnapProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.vs} style={vars}>
{/* 가로가 아니라 세로로 overflow-y:auto + scroll-snap-type: y 만으로 끝난다 — 0줄 JS */}
<div className={styles.viewport}>
<div className={styles.track}>
{[1, 2, 3].map((n) => (
<div key={n} className={`${styles.slide} ${styles["slide" + n]}`} />
))}
</div>
</div>
</div>
);
}05 · 세로 스냅 — 측정 누적면적 18.622% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
모바일 풀스크린에
세로형 배너나 모바일 풀스크린 소개 화면에 씁니다.
.vs__viewport {
height: 100%;
overflow-y: auto;
scroll-snap-type: y mandatory;
}
.vs__track { display: flex; flex-direction: column; }
.vs__slide { flex: 0 0 150px; scroll-snap-align: start; }
06커버플로우 스냅
가운데 카드만 정면
라디오 없이 실제 가로 스크롤과 scroll-snap-align: center만으로, 가운데 카드는 정면을 보고 양옆 카드는 자기 자리에서부터 이미 비스듬히 기울어 있습니다.

<main class="stage">
<div class="cf is-demo">
<div class="cf__viewport">
<div class="cf__track">
<div class="cf__slide cf__slide--1"></div>
<div class="cf__slide cf__slide--2"></div>
<div class="cf__slide cf__slide--3"></div>
<div class="cf__slide cf__slide--4"></div>
<div class="cf__slide cf__slide--5"></div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base;
$easing: $ease-spring;
$color: $stage-orange;
@include stage($stage-yellow);
.cf { width: min(360px, 88vw); }
// ── 실제 동작: 실제 가로 스크롤 + scroll-snap-align: center. 라디오 없음 (0줄 JS) ──
.cf__viewport {
overflow-x: auto;
overflow-y: hidden;
scroll-snap-type: x mandatory;
perspective: 640px;
padding: 0 40%; // 첫·끝 카드도 가운데까지 스크롤될 여백
box-sizing: border-box;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
}
.cf__track {
display: flex;
align-items: center;
width: max-content;
transform-style: preserve-3d;
}
.cf__slide {
flex: 0 0 min(120px, 30vw);
aspect-ratio: 3 / 4;
margin-right: $sp-4;
border-radius: $r-card;
scroll-snap-align: center;
transform-style: preserve-3d;
&:last-child { margin-right: 0; }
}
// 카드는 자기 순서에서부터 이미 비스듬하다 — 가운데(3번)만 정면, 양옆은 미리 기운 채로 놓여 있다
.cf__slide--1, .cf__slide--5 { transform: rotateY(38deg) scale(.72); opacity: .55; }
.cf__slide--2, .cf__slide--4 { transform: rotateY(24deg) scale(.86); opacity: .8; }
.cf__slide--3 { transform: rotateY(0deg) scale(1); }
.cf__slide--4 { transform: rotateY(-24deg) scale(.86); opacity: .8; }
.cf__slide--5 { transform: rotateY(-38deg) scale(.72); opacity: .55; }
.cf__slide--1 { background: linear-gradient(160deg, $subject-blue, #d6ddeb); }
.cf__slide--2 { background: linear-gradient(160deg, $color, #ebdbd6); }
.cf__slide--3 { background: linear-gradient(160deg, #c2ae82, $stage-yellow); }
.cf__slide--4 { background: linear-gradient(160deg, $subject-ink, #4a3f5c); }
.cf__slide--5 { background: linear-gradient(160deg, $subject-blue, $color); }
// ── 데모 루프: 클릭·드래그 전까지 트랙이 스스로 흐르며 가운데 카드가 바뀐다 ──
.cf.is-demo .cf__track { animation: cf-loop $dur-loop $easing infinite; }
@keyframes cf-loop {
0%, 8% { transform: translateX(0px); }
50%, 58% { transform: translateX(-140px); }
100% { transform: translateX(0px); }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.cf.is-demo .cf__track { animation: none !important; }
.cf__viewport { scroll-behavior: auto; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cf {
width: min(360px, 88vw);
}
.cf__viewport {
overflow-x: auto;
overflow-y: hidden;
scroll-snap-type: x mandatory;
perspective: 640px;
padding: 0 40%;
box-sizing: border-box;
scrollbar-width: none;
}
.cf__viewport::-webkit-scrollbar {
display: none;
}
.cf__track {
display: flex;
align-items: center;
width: max-content;
transform-style: preserve-3d;
}
.cf__slide {
flex: 0 0 min(120px, 30vw);
aspect-ratio: 3/4;
margin-right: 16px;
border-radius: 16px;
scroll-snap-align: center;
transform-style: preserve-3d;
}
.cf__slide:last-child {
margin-right: 0;
}
.cf__slide--1, .cf__slide--5 {
transform: rotateY(38deg) scale(0.72);
opacity: 0.55;
}
.cf__slide--2, .cf__slide--4 {
transform: rotateY(24deg) scale(0.86);
opacity: 0.8;
}
.cf__slide--3 {
transform: rotateY(0deg) scale(1);
}
.cf__slide--4 {
transform: rotateY(-24deg) scale(0.86);
opacity: 0.8;
}
.cf__slide--5 {
transform: rotateY(-38deg) scale(0.72);
opacity: 0.55;
}
.cf__slide--1 {
background: linear-gradient(160deg, #2f6df6, #d6ddeb);
}
.cf__slide--2 {
background: linear-gradient(160deg, #ff4d1f, #ebdbd6);
}
.cf__slide--3 {
background: linear-gradient(160deg, #c2ae82, #ffd23f);
}
.cf__slide--4 {
background: linear-gradient(160deg, #17141a, #4a3f5c);
}
.cf__slide--5 {
background: linear-gradient(160deg, #2f6df6, #ff4d1f);
}
.cf.is-demo .cf__track {
animation: cf-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes cf-loop {
0%, 8% {
transform: translateX(0px);
}
50%, 58% {
transform: translateX(-140px);
}
100% {
transform: translateX(0px);
}
}
@media (prefers-reduced-motion: reduce) {
.cf.is-demo .cf__track {
animation: none !important;
}
.cf__viewport {
scroll-behavior: auto;
}
}// 실제 동작은 overflow-x:auto + scroll-snap-align: center + perspective 만으로 끝난다(0줄).
// 라디오·JS 로 "몇 번이 가운데인지" 고르지 않는다 — 카드는 자기 자리에서부터 이미 기울어 있고,
// 실제 드래그·스크롤이 그 카드를 가운데로 데려온다. 이 3줄은 미리보기 자동 재생만 멈춘다.
document.querySelector('.cf__viewport').addEventListener('pointerdown', function () {
document.querySelector('.cf').classList.remove('is-demo');
});import type { CSSProperties } from "react";
import styles from "./CoverFlowSnap.module.scss";
export type CoverFlowSnapProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function CoverFlowSnap({ duration = 300, easing = "cubic-bezier(.2,.8,.2,1)", color = "#ff4d1f" }: CoverFlowSnapProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.cf} style={vars}>
{/* 라디오도 JS 도 없다 — scroll-snap-align: center 로 실제 드래그가 카드를 가운데로 데려온다.
카드는 자기 순서에서부터 이미 기울어 있다 (perspective + rotateY 고정값) */}
<div className={styles.viewport}>
<div className={styles.track}>
{[1, 2, 3, 4, 5].map((n) => (
<div key={n} className={`${styles.slide} ${styles["slide" + n]}`} />
))}
</div>
</div>
</div>
);
}06 · 커버플로우 스냅 — 측정 누적면적 31.972% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
포트폴리오를 넘길 때
포트폴리오 작품이나 앨범 커버를 넘기는 화면에 어울립니다.
.cf__viewport {
overflow-x: auto;
scroll-snap-type: x mandatory;
perspective: 640px;
}
.cf__slide { scroll-snap-align: center; }
.cf__slide--1, .cf__slide--5 { transform: rotateY(38deg) scale(.72); }
.cf__slide--2, .cf__slide--4 { transform: rotateY(24deg) scale(.86); }
.cf__slide--3 { transform: rotateY(0deg) scale(1); }
07카드 스택 스와이프
같은 자리에 겹쳐 쌓는다
카드 여러 장을 같은 자리에 겹쳐 쌓아두고, 맨 위 카드가 옆으로 미끄러지며 빠지면 다음 카드가 그 자리를 채우는 방식이라 스크롤 상자 자체가 없습니다.

<main class="stage">
<div class="cs">
<div class="cs__deck">
<div class="cs__card cs__card--1"></div>
<div class="cs__card cs__card--2"></div>
<div class="cs__card cs__card--3"></div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-orange);
.cs { width: min(170px, 38vw); }
// ── 실제 동작: 카드 3장이 같은 자리(grid-area: 1/1)에 겹쳐 있다. 스크롤 상자가 없다 ──
.cs__deck {
display: grid;
aspect-ratio: 3 / 4;
}
.cs__card {
grid-area: 1 / 1;
border-radius: $r-card;
box-shadow: $shadow-raised;
animation: cs-cycle $dur-loop $easing infinite;
}
.cs__card--1 { background: linear-gradient(160deg, $color, #d6ddeb); animation-delay: -0s; }
.cs__card--2 { background: linear-gradient(160deg, $stage-yellow, #ebe7d6); animation-delay: -.667s; }
.cs__card--3 { background: linear-gradient(160deg, $subject-ink, #4a3f5c); animation-delay: -1.333s; }
// 맨 위 카드가 옆으로 미끄러지며 빠지고, 빠진 카드는 스택 맨 뒤에서 다시 앞으로 올라온다
@keyframes cs-cycle {
0%, 4% { transform: translate(0, 0) scale(1) rotate(0deg); opacity: 1; z-index: 3; }
29% { transform: translate(42%, 6%) scale(1) rotate(12deg); opacity: 0; z-index: 3; }
29.1% { transform: translate(-6%, 6%) scale(.9) rotate(-4deg); opacity: 0; z-index: 1; }
33%, 62% { transform: translate(-6%, 6%) scale(.9) rotate(-4deg); opacity: .85; z-index: 1; }
66%, 96% { transform: translate(6%, 4%) scale(.95) rotate(3deg); opacity: .92; z-index: 2; }
100% { transform: translate(0, 0) scale(1) rotate(0deg); opacity: 1; z-index: 3; }
}
// 자동 순환이 전부다 — 마우스를 올리면 잠깐 멈춰서 지금 카드를 편하게 본다
.cs:hover .cs__card { animation-play-state: paused; }
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.cs__card { animation: none !important; }
.cs__card--1 { opacity: 1; z-index: 3; transform: none; }
.cs__card--2, .cs__card--3 { opacity: 0; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cs {
width: min(170px, 38vw);
}
.cs__deck {
display: grid;
aspect-ratio: 3/4;
}
.cs__card {
grid-area: 1/1;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
animation: cs-cycle 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.cs__card--1 {
background: linear-gradient(160deg, #2f6df6, #d6ddeb);
animation-delay: -0s;
}
.cs__card--2 {
background: linear-gradient(160deg, #ffd23f, #ebe7d6);
animation-delay: -0.667s;
}
.cs__card--3 {
background: linear-gradient(160deg, #17141a, #4a3f5c);
animation-delay: -1.333s;
}
@keyframes cs-cycle {
0%, 4% {
transform: translate(0, 0) scale(1) rotate(0deg);
opacity: 1;
z-index: 3;
}
29% {
transform: translate(42%, 6%) scale(1) rotate(12deg);
opacity: 0;
z-index: 3;
}
29.1% {
transform: translate(-6%, 6%) scale(0.9) rotate(-4deg);
opacity: 0;
z-index: 1;
}
33%, 62% {
transform: translate(-6%, 6%) scale(0.9) rotate(-4deg);
opacity: 0.85;
z-index: 1;
}
66%, 96% {
transform: translate(6%, 4%) scale(0.95) rotate(3deg);
opacity: 0.92;
z-index: 2;
}
100% {
transform: translate(0, 0) scale(1) rotate(0deg);
opacity: 1;
z-index: 3;
}
}
.cs:hover .cs__card {
animation-play-state: paused;
}
@media (prefers-reduced-motion: reduce) {
.cs__card {
animation: none !important;
}
.cs__card--1 {
opacity: 1;
z-index: 3;
transform: none;
}
.cs__card--2, .cs__card--3 {
opacity: 0;
}
}import type { CSSProperties } from "react";
import styles from "./StackedCardSwipe.module.scss";
export type StackedCardSwipeProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function StackedCardSwipe({ duration = 2000, easing = "cubic-bezier(.2,.8,.2,1)", color = "#2f6df6" }: StackedCardSwipeProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.cs} style={vars}>
{/* 카드 3장이 같은 자리(grid-area: 1/1)에 겹쳐 있고, @keyframes 하나가 순서대로 옆으로 빠진다 — 0줄 JS */}
<div className={styles.deck}>
{[1, 2, 3].map((n) => (
<div key={n} className={`${styles.card} ${styles["card" + n]}`} />
))}
</div>
</div>
);
}07 · 카드 스택 스와이프 — 측정 누적면적 41.574% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
추천 카드를 훑을 때
추천 카드를 한 장씩 훑어보는 화면에 씁니다.
.cs__card {
grid-area: 1 / 1;
animation: cycle 6s ease-in-out infinite;
}
@keyframes cycle {
0%, 4% { transform: translate(0, 0); opacity: 1; z-index: 3; }
29% { transform: translate(42%, 6%) rotate(12deg); opacity: 0; }
33%, 62% { transform: translate(-6%, 6%) rotate(-4deg); opacity: .85; }
66%, 96% { transform: translate(6%, 4%) rotate(3deg); opacity: .92; }
100% { transform: translate(0, 0); opacity: 1; z-index: 3; }
}
08켄번즈 줌 자동재생 히어로
보이는 사진만 천천히 확대
사진이 저절로 넘어가는 동안 지금 보이는 사진만 천천히 확대돼, 정지 화면인데도 계속 살아 움직이는 느낌을 줍니다.

<main class="stage">
<div class="kb">
<div class="kb__stage">
<div class="kb__card kb__card--1"><div class="kb__img kb__img--1"></div></div>
<div class="kb__card kb__card--2"><div class="kb__img kb__img--2"></div></div>
<div class="kb__card kb__card--3"><div class="kb__img kb__img--3"></div></div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base;
$easing: $ease-out;
$color: $stage-orange;
@include stage($stage-paper);
.kb { width: min(360px, 80vw); }
// ── 실제 동작: 카드가 같은 자리에서 오파시티로 교차되고, 보이는 카드의 배경만 계속 확대된다 (0줄 JS) ──
.kb__stage {
display: grid;
aspect-ratio: 16 / 10;
border-radius: $r-card;
overflow: hidden;
}
.kb__card { grid-area: 1 / 1; overflow: hidden; }
.kb__img { width: 100%; height: 100%; }
.kb__img--1 { background: radial-gradient(120% 120% at 30% 20%, $subject-blue, #14264d); }
.kb__img--2 { background: radial-gradient(120% 120% at 70% 30%, $color, #4a1c0d); }
.kb__img--3 { background: radial-gradient(120% 120% at 50% 70%, #c2ae82, #3a2a05); }
.kb__card--1 { animation: kb-fade-1 $dur-loop linear infinite; }
.kb__card--2 { animation: kb-fade-2 $dur-loop linear infinite; }
.kb__card--3 { animation: kb-fade-3 $dur-loop linear infinite; }
.kb__img--1 { animation: kb-zoom-1 $dur-loop $easing infinite; }
.kb__img--2 { animation: kb-zoom-2 $dur-loop $easing infinite; }
.kb__img--3 { animation: kb-zoom-3 $dur-loop $easing infinite; }
// 교차 페이드 창을 12% 로 넓게 — 마지막→처음 순간의 팝 없이 3번 페이드아웃(88→100%)과
// 1번 페이드인(0→12%)이 릴레이로 이어진다 (끝→처음 텔레포트 금지)
@keyframes kb-fade-1 { 0% { opacity: 0; } 12%, 25% { opacity: 1; } 37%, 100% { opacity: 0; } }
@keyframes kb-fade-2 { 0%, 25% { opacity: 0; } 37%, 58% { opacity: 1; } 70%, 100% { opacity: 0; } }
@keyframes kb-fade-3 { 0%, 58% { opacity: 0; } 70%, 88% { opacity: 1; } 100% { opacity: 0; } }
// 보이는 동안에만 천천히 확대되고, 다음 차례가 오면 다시 배율 1로 되돌아가 있다 (켄번즈 줌)
@keyframes kb-zoom-1 { 0% { transform: scale(1); } 29% { transform: scale(1.16); } 29.1%, 100% { transform: scale(1); } }
@keyframes kb-zoom-2 { 0%, 33% { transform: scale(1); } 62% { transform: scale(1.16); } 62.1%, 100% { transform: scale(1); } }
@keyframes kb-zoom-3 { 0%, 66% { transform: scale(1); } 96% { transform: scale(1.16); } }
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.kb__card, .kb__img { animation: none !important; }
.kb__card--1 { opacity: 1; }
.kb__card--2, .kb__card--3 { opacity: 0; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.kb {
width: min(360px, 80vw);
}
.kb__stage {
display: grid;
aspect-ratio: 16/10;
border-radius: 16px;
overflow: hidden;
}
.kb__card {
grid-area: 1/1;
overflow: hidden;
}
.kb__img {
width: 100%;
height: 100%;
}
.kb__img--1 {
background: radial-gradient(120% 120% at 30% 20%, #2f6df6, #14264d);
}
.kb__img--2 {
background: radial-gradient(120% 120% at 70% 30%, #ff4d1f, #4a1c0d);
}
.kb__img--3 {
background: radial-gradient(120% 120% at 50% 70%, #c2ae82, #3a2a05);
}
.kb__card--1 {
animation: kb-fade-1 2s linear infinite;
}
.kb__card--2 {
animation: kb-fade-2 2s linear infinite;
}
.kb__card--3 {
animation: kb-fade-3 2s linear infinite;
}
.kb__img--1 {
animation: kb-zoom-1 2s ease-out infinite;
}
.kb__img--2 {
animation: kb-zoom-2 2s ease-out infinite;
}
.kb__img--3 {
animation: kb-zoom-3 2s ease-out infinite;
}
@keyframes kb-fade-1 {
0% {
opacity: 0;
}
12%, 25% {
opacity: 1;
}
37%, 100% {
opacity: 0;
}
}
@keyframes kb-fade-2 {
0%, 25% {
opacity: 0;
}
37%, 58% {
opacity: 1;
}
70%, 100% {
opacity: 0;
}
}
@keyframes kb-fade-3 {
0%, 58% {
opacity: 0;
}
70%, 88% {
opacity: 1;
}
100% {
opacity: 0;
}
}
@keyframes kb-zoom-1 {
0% {
transform: scale(1);
}
29% {
transform: scale(1.16);
}
29.1%, 100% {
transform: scale(1);
}
}
@keyframes kb-zoom-2 {
0%, 33% {
transform: scale(1);
}
62% {
transform: scale(1.16);
}
62.1%, 100% {
transform: scale(1);
}
}
@keyframes kb-zoom-3 {
0%, 66% {
transform: scale(1);
}
96% {
transform: scale(1.16);
}
}
@media (prefers-reduced-motion: reduce) {
.kb__card, .kb__img {
animation: none !important;
}
.kb__card--1 {
opacity: 1;
}
.kb__card--2, .kb__card--3 {
opacity: 0;
}
}import type { CSSProperties } from "react";
import styles from "./AutoplayKenburns.module.scss";
export type AutoplayKenburnsProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function AutoplayKenburns({ duration = 6000, easing = "ease-out", color = "#ff4d1f" }: AutoplayKenburnsProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.kb} style={vars}>
{/* 카드가 같은 자리에서 오파시티로 교차되고, 보이는 카드의 배경만 계속 확대된다 — 0줄 JS */}
<div className={styles.stageBox}>
{[1, 2, 3].map((n) => (
<div key={n} className={`${styles.card} ${styles["card" + n]}`}>
<div className={`${styles.img} ${styles["img" + n]}`} />
</div>
))}
</div>
</div>
);
}08 · 켄번즈 줌 자동재생 히어로 — 측정 누적면적 56.128% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
히어로 배너에
메인 히어로 배너나 브랜드 소개 풀스크린 슬라이드에 씁니다.
.kb__card { grid-area: 1 / 1; animation: fade1 6s linear infinite; }
.kb__img { animation: zoom1 6s ease-out infinite; }
@keyframes fade1 { 0%, 4% { opacity: 1; } 29%, 100% { opacity: 0; } }
@keyframes zoom1 {
0% { transform: scale(1); }
29% { transform: scale(1.16); }
29.1%, 100% { transform: scale(1); }
}
09스크롤 연동 진행 바
스크롤 위치를 그대로 읽는다
라디오나 자바스크립트로 계산하지 않고, animation-timeline이 실제 가로 스크롤 위치를 그대로 읽어 진행 바를 채웁니다.

<main class="stage">
<div class="sp is-demo">
<div class="sp__bar"><div class="sp__fill"></div></div>
<div class="sp__viewport">
<div class="sp__track">
<div class="sp__slide sp__slide--1"></div>
<div class="sp__slide sp__slide--2"></div>
<div class="sp__slide sp__slide--3"></div>
<div class="sp__slide sp__slide--4"></div>
<div class="sp__slide sp__slide--1" aria-hidden="true"></div>
<div class="sp__slide sp__slide--2" aria-hidden="true"></div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base;
$easing: linear;
$color: $subject-blue;
@include stage($stage-ink);
.sp {
width: min(360px, 88vw);
// 진행 바(.sp__bar)와 스크롤 상자(.sp__viewport)는 형제라, 공용 이름을 이 공통 조상에서 열어 둔다
timeline-scope: --sp-progress;
}
.sp__bar {
height: 6px;
border-radius: $r-pill;
background: rgba(255, 255, 255, .18);
overflow: hidden;
margin-bottom: $sp-3;
}
.sp__fill {
height: 100%;
width: 0%;
border-radius: $r-pill;
background: $color;
// ── 실제 동작: 실제 가로 스크롤 위치를 이름 붙은 스크롤 타임라인으로 그대로 읽어 폭을 채운다 (0줄 JS) ──
animation-name: sp-fill;
animation-timeline: --sp-progress;
animation-fill-mode: both;
}
@keyframes sp-fill {
from { width: 0%; }
to { width: 100%; }
}
.sp__viewport {
overflow-x: auto;
scroll-snap-type: x mandatory;
scroll-timeline: --sp-progress inline;
border-radius: $r-card;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
}
.sp__track { display: flex; width: max-content; }
.sp__slide {
flex: 0 0 190px;
aspect-ratio: 4 / 3;
scroll-snap-align: start;
margin-right: $sp-3;
border-radius: $r-card;
&:last-child { margin-right: 0; }
}
.sp__slide--1 { background: linear-gradient(135deg, $color, #d6ddeb); }
.sp__slide--2 { background: linear-gradient(135deg, $stage-orange, #ebdbd6); }
.sp__slide--3 { background: linear-gradient(135deg, #c2ae82, $stage-yellow); }
.sp__slide--4 { background: linear-gradient(135deg, $subject-ink, #4a3f5c); }
// ── 데모 루프: 트랙만 미리보기로 스스로 넘어간다. 진행 바는 실제 스크롤 타임라인이 그대로 그린다 ──
// 트랙 끝에는 1번의 복제본(aria-hidden)이 한 장 더 — 그곳까지 정상 속도로 가고
// 100%→0% 점프는 복제본이 1번과 같아 안 보인다 (끝→처음 텔레포트 금지)
.sp.is-demo .sp__track { animation: sp-loop $dur-loop $ease-spring infinite; }
@keyframes sp-loop {
0%, 8% { transform: translateX(0px); }
23%, 31% { transform: translateX(-202px); }
46%, 54% { transform: translateX(-404px); }
69%, 77% { transform: translateX(-606px); }
92%, 100% { transform: translateX(-808px); }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.sp.is-demo .sp__track { animation: none !important; }
.sp__viewport { scroll-behavior: auto; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sp {
width: min(360px, 88vw);
timeline-scope: --sp-progress;
}
.sp__bar {
height: 6px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.18);
overflow: hidden;
margin-bottom: 12px;
}
.sp__fill {
height: 100%;
width: 0%;
border-radius: 999px;
background: #2f6df6;
animation-name: sp-fill;
animation-timeline: --sp-progress;
animation-fill-mode: both;
}
@keyframes sp-fill {
from {
width: 0%;
}
to {
width: 100%;
}
}
.sp__viewport {
overflow-x: auto;
scroll-snap-type: x mandatory;
scroll-timeline: --sp-progress inline;
border-radius: 16px;
scrollbar-width: none;
}
.sp__viewport::-webkit-scrollbar {
display: none;
}
.sp__track {
display: flex;
width: max-content;
}
.sp__slide {
flex: 0 0 190px;
aspect-ratio: 4/3;
scroll-snap-align: start;
margin-right: 12px;
border-radius: 16px;
}
.sp__slide:last-child {
margin-right: 0;
}
.sp__slide--1 {
background: linear-gradient(135deg, #2f6df6, #d6ddeb);
}
.sp__slide--2 {
background: linear-gradient(135deg, #ff4d1f, #ebdbd6);
}
.sp__slide--3 {
background: linear-gradient(135deg, #c2ae82, #ffd23f);
}
.sp__slide--4 {
background: linear-gradient(135deg, #17141a, #4a3f5c);
}
.sp.is-demo .sp__track {
animation: sp-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes sp-loop {
0%, 8% {
transform: translateX(0px);
}
23%, 31% {
transform: translateX(-202px);
}
46%, 54% {
transform: translateX(-404px);
}
69%, 77% {
transform: translateX(-606px);
}
92%, 100% {
transform: translateX(-808px);
}
}
@media (prefers-reduced-motion: reduce) {
.sp.is-demo .sp__track {
animation: none !important;
}
.sp__viewport {
scroll-behavior: auto;
}
}// 진행 바(.sp__fill)는 라디오도 JS 도 없이 animation-timeline 으로 실제 스크롤 위치를 그대로 읽는다(0줄).
// 슬라이드 트랙만 클릭·드래그 전까지 미리보기로 스스로 넘어간다 — 실제 스크롤은 그대로 살아있다.
document.querySelector('.sp__viewport').addEventListener('pointerdown', function () {
document.querySelector('.sp').classList.remove('is-demo');
});import type { CSSProperties } from "react";
import styles from "./ScrollTimelineProgress.module.scss";
export type ScrollTimelineProgressProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function ScrollTimelineProgress({ duration = 300, easing = "linear", color = "#2f6df6" }: ScrollTimelineProgressProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.sp} style={vars}>
{/* 진행 바는 라디오도 JS 도 없이 animation-timeline 으로 실제 스크롤 위치를 그대로 읽는다 — 0줄 JS */}
<div className={styles.bar}><div className={styles.fill} /></div>
<div className={styles.viewport}>
<div className={styles.track}>
{[1, 2, 3, 4].map((n) => (
<div key={n} className={`${styles.slide} ${styles["slide" + n]}`} />
))}
</div>
</div>
</div>
);
}09 · 스크롤 연동 진행 바 — 측정 누적면적 36.869% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
몇 장인지 보여줄 때
슬라이드가 몇 장인지 한눈에 보여주고 싶은 스토리 배너에 씁니다.
.sp { timeline-scope: --sp-progress; }
.sp__viewport { scroll-timeline: --sp-progress inline; }
.sp__fill {
animation-name: fill;
animation-timeline: --sp-progress;
animation-fill-mode: both;
}
@keyframes fill { from { width: 0%; } to { width: 100%; } }
어디서 깨지는가 — 함정 하나
형제 요소에는 타임라인이 안 보인다
09번(스크롤 연동 진행 바)에서 가장 크게 걸린 함정은 진행 바와 스크롤 상자가 형제 요소라는 점이었습니다. scroll-timeline으로 이름 붙인 타임라인은 그 이름을 선언한 요소의 자손에게만 기본으로 보입니다. 진행 바(.sp__fill)는 스크롤 상자(.sp__viewport) 밖에, 그 위에 따로 있는 막대라서 처음엔 animation-timeline: --sp-progress를 걸어도 진행률이 전혀 움직이지 않았습니다.
timeline-scope 로 이름을 연다
공통 조상에 timeline-scope: --sp-progress를 선언해 그 이름을 형제 쪽까지 열어 주고 나서야 막대가 실제 스크롤을 따라 채워졌습니다. animation-timeline과 timeline-scope는 설치된 크롬에서 직접 렌더해 확인한 기능이라, 아직 못 따라온 브라우저에서는 막대가 그냥 비어 있을 뿐 슬라이드 넘기기 자체는 멀쩡히 돌아갑니다. 덮개층 위에 또 덮개가 올라가는 충돌은 쿠키 동의 배너 UI에서도 같은 쌓임 순서 문제로 나타나 약관창이 배너 밑에 깔립니다. 아홉 개 폴더를 전부 이렇게 다시 확인하고서야 압축했고, b2earrkf을 문서 안내대로 넣으면 고친 결과가 그대로 눈앞에서 열립니다.
접근성
css 캐러셀은 모션 줄이기에서 어떻게 되나?
아홉 개 모두 prefers-reduced-motion: reduce에서 자동으로 움직이는 부분(07·08의 자동 순환, 01·03·04·05·06·09의 미리보기 루프)이 꺼지고 지금 상태로 고정됩니다.
키보드로 넘기기
스크롤 스냅을 쓰는 01·03·04·05·06·09는 Tab과 화살표 키만으로 다음 칸으로 넘어가고, 02의 라디오 슬라이더는 네이티브 폼 컨트롤이라 화살표 키로 바로 선택이 바뀝니다.
스크린 리더 라벨
03·04의 점·썸네일에는 aria-label="슬라이드 N"을 달아 스크린 리더가 몇 번째인지 구분할 수 있게 했습니다.
FAQ
이미지 슬라이더를 라디오와 스크롤 스냅 중 뭘로 만들까요?
실제로 드래그해서 넘기는 느낌이 필요하면 스크롤 스냅(01·03~06·09)을, 화살표나 점만으로 딱 떨어지게 넘기고 싶으면 라디오 방식(02)을 씁니다. 스크롤 스냅은 터치 기기에서 손가락으로 바로 밀 수 있다는 게 강점이고, 라디오 방식은 트랙 위치를 라디오 하나로 정확히 고정할 수 있다는 게 강점입니다.
animation-timeline은 모든 브라우저에서 되나요?
설치된 크롬으로 직접 렌더해 값을 재 본 결과라 크로미움 밖은 장담하지 못합니다. 다만 09번의 진행 바만 못 움직일 뿐 스크롤 스냅 자체는 그대로라, 슬라이드를 넘기는 데는 지장이 없습니다.
React 버전은 원리가 다른가요?
01·05·06·07·08·09는 CSS 모듈에 같은 스크롤 스냅·키프레임·animation-timeline을 그대로 옮겨서 원리가 동일합니다. 02(라디오)는 useState 하나로 바꿨고, 03·04(:target)는 여러 인스턴스를 한 페이지에 놓아도 아이디가 겹치지 않도록 scrollIntoView로 바꿨습니다. duration·easing·color는 모두 props로 받습니다.
CSS만으로 만드는 다른 애니메이션은 CSS 애니메이션 카테고리에서 더 찾을 수 있고, 사이트 운영 방식은 소개 페이지에 적어 뒀습니다. scroll-snap-type의 정확한 동작은 MDN scroll-snap-type 문서에서, animation-timeline은 MDN animation-timeline 문서에서 확인할 수 있습니다. 넘기는 두 사진을 한 장에서 비교하게 만들려면 이미지 비교 슬라이더가 그 손잡이를 줍니다.









