9 CSS Only Carousel Slider Recipes to Copy-Paste
A CSS only carousel slider is a row of photos or cards that steps through one at a time in the same frame, built nine ways here — scroll snap to radio hack,
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
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Peek-next scroll-snap
- 02 Radio + arrow slider
- 03 Dot nav via :target
- 04 Thumbnail-synced snap
- 05 Vertical snap
- 06 Cover-flow snap
- 07 Stacked card swipe
- 08 Autoplay hero with Ken Burns zoom
- 09 Scroll-linked progress bar
The order isn't a popularity ranking — it follows how a carousel actually gets built. 01 is the plainest version, a strip you can just drag. 02 through 04 add ways to jump straight to one slide — arrows, dots, thumbnails — while staying script-free. 05 and 06 change the shape of the same idea, one turning the axis vertical, the other tilting every card in 3D. 07 through 09 close with three that don't need anyone to touch them at all: a card stack that swipes itself, a zooming hero, and a bar that reads the scroll position directly. A companion that travels well with a carousel is the back to top button in the corner of the screen, and a layer pinned above a strip of slides is the sticky header variants.
01Peek-next scroll-snap
Why the next card peeks
Each card leaves enough side padding that the next slide peeks into view at the edge of the frame, and scroll-padding-left keeps the first card snapped flush against the left side instead of drifting under the container's own edge.

<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 · Peek-next scroll-snap — Measured area 37.5% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where it fits
It suits a shop's product row or a short row of recommended reads, where a visitor should sense there's more without an arrow having to say so.
.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;
}
02Radio + arrow slider
Three radios and two labels
Three hidden radio inputs and a pair of arrow-shaped labels are the whole mechanism here — checking one radio doesn't just toggle a class on its own slide — it shifts the entire track sideways by a third through a sibling selector.

<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="Next slide">›</label>
</div>
<div class="ra__slide ra__slide--2">
<label class="ra__prev" for="ra-1" aria-label="Previous slide">‹</label>
<label class="ra__next" for="ra-3" aria-label="Next slide">›</label>
</div>
<div class="ra__slide ra__slide--3">
<label class="ra__prev" for="ra-2" aria-label="Previous slide">‹</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 · Radio + arrow slider — Measured area 50.374% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password
The oldest css only carousel with arrows
It's the oldest no-scroll, no-JS way to build a carousel, and it still suits a banner or a notice card someone flips through by hand only.
.ra__viewport { overflow: hidden; }
.ra__track {
display: flex;
width: 300%;
transition: transform 300ms;
}
#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%); }
03Dot nav via :target
A dot click is a real link
Clicking a dot is a real link click, nothing more — the address bar's # fragment changes and the browser scrolls that slide into view inside the snap track entirely on its own.

<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="Slide 1"></a>
<a href="#dt-s2" class="dt__dot dt__dot--2" aria-label="Slide 2"></a>
<a href="#dt-s3" class="dt__dot dt__dot--3" aria-label="Slide 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 · Dot nav via :target — Measured area 35.13% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password
Why the back button works
:target only steps in afterward, to color whichever dot matches the slide now showing, which is why the back button correctly returns to the previous slide.
.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);
}
04Thumbnail-synced snap
A css only image slider needs real thumbnails
A real strip of square thumbnail images sits below the main photo, each one a plain anchor pointing at its matching slide, so the same :has() trick from the dots above now outlines a thumbnail instead of coloring a dot.
<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="Slide 1 thumbnail"></a>
<a href="#tm-s2" class="tm__thumb tm__thumb--2" aria-label="Slide 2 thumbnail"></a>
<a href="#tm-s3" class="tm__thumb tm__thumb--3" aria-label="Slide 3 thumbnail"></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 · Thumbnail-synced snap — Measured area 26.853% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password
Alt text for every frame
Because these are actual image elements rather than generated markers, each one can carry its own alt text for a product-detail gallery.
.tm__thumb {
border: 3px solid transparent;
transition: border-color 300ms;
}
.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;
}
05Vertical snap
Turning the axis vertical
Swap the scroll axis from horizontal to vertical and scroll-snap keeps working exactly the same way, so each slide fills the box top to bottom instead of side to side — the same direction a phone's story viewer scrolls in.

<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 · Vertical snap — Measured area 18.622% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where a vertical strip reads better
A vertical story banner or a mobile full-screen intro reads naturally in this direction where a sideways strip wouldn't.
.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; }
06Cover-flow snap
No radio decides the front card
No radio input picks which card faces forward — real horizontal dragging is the only control, and every card is already tilted with a fixed rotateY and scale set purely by its position in the row.

<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 · Cover-flow snap — Measured area 31.972% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password
Why the center looks centered
Whichever card scrolls itself to the center happens to be the one sitting at rotateY(0deg), so it just looks centered without any state ever changing.
.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); }
07Stacked card swipe
Three cards in one grid cell
Three cards sit stacked in the exact same grid cell with no scroll box anywhere in sight, and all three play the identical @keyframes loop — only animation-delay staggers when each one starts, which alone is enough to make the top card peel off sideways while the next one rises to take its spot.

<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 · Stacked card swipe — Measured area 41.574% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password
Pausing the stack
Hovering the stack is the only way to pause it, via animation-play-state: paused.
.cs__card {
grid-area: 1 / 1;
animation: cs-cycle 6s ease-in-out infinite;
}
.cs__card--2 { animation-delay: -.667s; }
.cs__card--3 { animation-delay: -1.333s; }
@keyframes cs-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; }
}
08Autoplay hero with Ken Burns zoom
Cross-fade plus a private zoom
Three photos cross-fade on their own timer, and the one currently on screen also runs its own private zoom — scale climbs from 1 to 1.16 only for as long as that photo is visible, then resets before its next turn comes around.

<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 · Autoplay hero with Ken Burns zoom — Measured area 56.128% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password
What keeps a still hero alive
That extra layer is what keeps a still hero banner feeling alive instead of just cutting between flat frames.
.kb__card { grid-area: 1 / 1; animation: kb-fade 6s linear infinite; }
.kb__img { animation: kb-zoom 6s ease-out infinite; }
@keyframes kb-fade { 0%, 4% { opacity: 1; } 29%, 100% { opacity: 0; } }
@keyframes kb-zoom {
0% { transform: scale(1); }
29% { transform: scale(1.16); }
29.1%, 100% { transform: scale(1); }
}
09Scroll-linked progress bar
Reading the scroll position directly
No radio hack and no JavaScript reading a scroll offset — animation-timeline points a keyframe animation at a named scroll-timeline and lets the real horizontal scroll position fill the bar's width directly, frame for frame.

<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 · Scroll-linked progress bar — Measured area 36.869% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
What the render measured
Measuring the render showed 23 of the 24 captured frames moving, more than any other recipe in this set, because the bar tracks the scroll continuously instead of jumping between fixed states.
.sp { timeline-scope: --sp-progress; }
.sp__viewport { scroll-timeline: --sp-progress inline; }
.sp__fill {
animation-name: sp-fill;
animation-timeline: --sp-progress;
animation-fill-mode: both;
}
@keyframes sp-fill { from { width: 0%; } to { width: 100%; } }
Where does this break?
A sibling cannot see the timeline
Item 09's progress bar shipped broken the first time it was wired up. The bar (.sp__bar) and the scroll box (.sp__viewport) are siblings, not parent and child, and a scroll-timeline-name declared on one element is only visible to that element's own descendants by default — a sibling can't see it at all. The bar sat frozen at 0% width no matter how far the slides scrolled, because animation-timeline: --sp-progress on the fill had nothing to attach to.
What timeline-scope opens up
Declaring timeline-scope: --sp-progress on their shared parent is what opens that name up to the sibling side of the tree, and only after adding it did the bar start filling in step with the real scroll. Rendering it in an installed Chrome build confirmed the fix directly, frame by frame, from empty to about half full to completely filled; a browser that hasn't shipped animation-timeline yet just leaves the bar empty while scrolling and snapping keep working normally. Every one of the nine folders got re-checked this way before the zip was sealed, and typing b2earrkf exactly where the download page shows it unlocks the corrected set. A cover stacked on a cover is the same z-order trouble the cookie consent banner patterns run into, with a policy dialog buried under the banner.
Variants worth trying
| Variant | Changed value | Feel |
|---|---|---|
| Slower radio flip | Item 02's track transition stretched from 300ms to 500ms | The panel change reads as a deliberate page turn instead of a quick swap |
| Flatter cover flow | Item 06's side-card angles cut from 38deg/24deg to 20deg/10deg | Neighboring covers stay mostly readable instead of nearly edge-on |
| Eased progress fill | Item 09's animation-timeline keyframe swapped from linear to ease-out |
The bar rushes early in the scroll and settles into the last stretch, instead of moving at one constant rate |
Accessibility
Does a css only carousel autoplay still move under reduced motion?
Under prefers-reduced-motion: reduce, every item's own demo-preview loop turns off with animation: none !important, leaving only what a real drag, click, radio change, or anchor jump still causes. Items 07 and 08 stop mid-cycle and lock onto their first card so nothing keeps drifting on its own; the scroll-snap items (01, 03, 04, 05, 06, 09) simply set scroll-behavior: auto, so Tab and the arrow keys still move focus one slide at a time, just without an eased glide. MDN's prefers-reduced-motion reference lists exactly which platform settings trigger it.
Keyboard and screen reader paths
Item 02's radio group is a native form control, so arrow keys already switch slides with no extra markup at all. The dots in item 03 and the thumbnails in item 04 each carry an aria-label="Slide N" so a screen reader can tell them apart even though they render with no visible text.
Where to go next
None of the nine needs a 3D-tilted radio state or a ::scroll-marker to work — for that route through the same idea, see 9 CSS-Only Carousels You Can Copy-Paste, a separate set built around the browser-generated scroll markers this one deliberately avoids. More scroll-driven CSS sits in the CSS category hub, and MDN's scroll-snap-type entry covers exactly how snapping chooses a stopping point.
FAQ
Do any of these nine need JavaScript to actually run?
No — every slide change comes from native scrolling, a radio input's :checked state, or a plain anchor link's :target. The only script in each demo file is a three-line listener that stops the auto-playing preview loop once a visitor drags or clicks; deleting it removes nothing but that preview.
What is the css only carousel browser support for the item 09 progress bar?
It was measured in an installed Chrome build, where animation-timeline and timeline-scope are supported and the fill was confirmed moving frame by frame. Where those properties aren't supported yet, the bar just stays empty — the slides underneath still scroll and snap normally, so nothing about navigating the carousel breaks.
How different is the React version underneath?
Items 01, 05, 06, 07, 08, and 09 carry the same scroll-snap rules, keyframes, and animation-timeline straight into a CSS module, so the mechanism is identical. Item 02 swaps its radio group for one useState index, and item 03 (and 04 the same way) replaces :target with a scrollIntoView call so multiple carousel instances on one page never collide over the same anchor id; duration, easing, and color all become props either way. For two photos compared inside one frame, the before after image sliders hand over the handle.









