css カルーセル スライダーの作り方9選 — コピペでOK
css カルーセル スライダーとは、写真やカードを何枚か並べて画面の中で順番に切り替えて見せる部品のことです。指でめくったりボタンを押したりすると次の一枚が現れ、JavaScriptを書かなくてもCSSだけで動かせます。スクロールからラジオ技まで、9種類の作り方をそのままコピペして使える形でまとめました。
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
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 次のスライドが少し見える
- 02 ラジオ+矢印スライダー
- 03 :targetドットナビ
- 04 サムネイル連動スナップ
- 05 縦スナップ
- 06 カバーフロースナップ
- 07 カードスタックスワイプ
- 08 ケンバーンズ自動再生ヒーロー
- 09 スクロール連動プログレスバー
9個は人気順ではなく、実際にカルーセルを組むときに手を付ける順に並べています。指でめくる最も基本の形(01)から、矢印・ドット・サムネイルで操作方法を変えていく段階(02〜04)へ進み、そのあとスクロールする向きと見え方を変える段階(05〜06)を経て、最後は何も押さなくても自分から動いたり今どの辺りにいるかを教えてくれる段階(07〜09)という流れです。カルーセルと一緒に使いやすい相棒は画面の隅にあるトップへ戻るボタンで、スライド束の上に貼り付いて追従する層が要るならCSSスティッキーヘッダーの追加があります。
01次のスライドが少し見える
カード同士の間隔を広く
カード同士の間隔を広めに取ってあるので、今見えている一枚の隣にもう一枚がわずかにのぞきます。scroll-padding-leftで最初のカードは画面の左端にぴったり合わせて止まり、実際に指でドラッグしたりホイールで送ったりするだけなのでJavaScriptは要りません。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
続きがあると伝える場面
通販サイトの商品カード一覧のように「まだ続きがある」と伝えたい場面に向いています。
.pn__viewport {
overflow-x: auto;
scroll-snap-type: x mandatory;
scroll-padding-left: $sp-4;
}
.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;
}
02ラジオ+矢印スライダー
css カルーセル ボタン 付きの古典
隠したラジオボタン3個と矢印形のラベルだけでスライドを1枚ずつ送る、スクロール不要のもっとも古典的なCSSカルーセルの作り方です。矢印を押すと対応するラジオが選ばれ、トラック全体が33.33%ずつ横へ動きます。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どんな場面に向くか
バナーや、手動でだけ送りたいお知らせカードに向いています。
.ra__track {
display: flex;
width: 300%;
transition: transform $duration $easing;
}
#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セレクターは今指しているスライドを判定するためだけに使い、:has()で該当するドットだけを塗ります。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
戻るボタンとURL共有
戻るボタンで前のスライドに戻したいギャラリーや、URLだけで特定の一枚を共有したい場面に向いています。
.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: $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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
画像ギャラリーに
商品詳細ページの画像ギャラリーや、複数カットの写真をめくる場面に向いています。
.tm__thumb {
border: 3px solid transparent;
transition: border-color $duration $easing;
}
.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;
}
05縦スナップ
css スライダー 縦にするだけ
横ではなく縦にスクロールし、画面の高さいっぱいにスライドが1枚ずつぴったり止まります。overflow-yとscroll-snap-type: yを使うだけで、スマホのストーリー画面と同じ向きのカルーセルになります。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
ストーリーバナー向き
縦型のストーリーバナーやモバイルのフルスクリーン紹介スライドに向いています。
.vs__viewport {
height: 100%;
overflow-y: auto;
scroll-snap-type: y mandatory;
}
.vs__track { display: flex; flex-direction: column; height: max-content; }
.vs__slide { flex: 0 0 150px; width: 100%; 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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
ポートフォリオめくりに
ポートフォリオの作品めくりや、アルバム・グッズのカバーを見せる場面に向いています。
.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カードスタックスワイプ
同じ位置に重ねたカード
カードを何枚も同じ位置に重ねて置き、一番上のカードが横に滑って抜けると次のカードがその場所を埋めます。スクロールする箱そのものがなく、同じ@keyframesをカードごとにanimation-delayだけずらして自動で循環させています。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
1枚ずつ見せる場面
マッチングアプリのようなカードめくりや、おすすめカードを1枚ずつ見せる場面に向いています。
.cs__card {
grid-area: 1 / 1;
animation: cs-cycle $dur-loop $easing infinite;
}
.cs__card--1 { animation-delay: -0s; }
.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; }
}
08ケンバーンズ自動再生ヒーロー
切り替えと拡大を重ねる
写真が自動で切り替わる間、今画面に映っている一枚だけがゆっくり拡大し続けるので、静止画なのにずっと動いているように見えます。クロスフェードで写真を切り替えるアニメーションに、表示されている間だけscale(1)からscale(1.16)へ拡大するアニメーションを重ねています。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
ヒーローバナーに
メインのヒーローバナーやブランド紹介のフルスクリーンスライドに向いています。
.kb__card { grid-area: 1 / 1; animation: kb-fade-1 $dur-loop linear infinite; }
.kb__img { animation: kb-zoom-1 $dur-loop $easing infinite; }
@keyframes kb-fade-1 { 0%, 4% { opacity: 1; } 29%, 100% { opacity: 0; } }
@keyframes kb-zoom-1 {
0% { transform: scale(1); }
29% { transform: scale(1.16); }
29.1%, 100% { transform: scale(1); }
}
09スクロール連動プログレスバー
スクロール位置をそのまま読む
ラジオボタンもJavaScriptの計算も使わず、animation-timelineが実際の横スクロール位置をそのまま読み取って進捗バーの幅を埋めます。バーとスクロール枠は兄弟要素なので、共通の親に指定したtimeline-scopeがその名前を橋渡ししています。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
枚数を一目で示す
スライドが何枚あるか一目で示したいストーリーバナーに向いています。
.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%; }
}
どこで壊れるか — 落とし穴ひとつ
兄弟要素からは見えない
09番(スクロール連動プログレスバー)を最初に作ったとき、進捗バーがまったく動きませんでした。進捗バーの.sp__fillとスクロール枠の.sp__viewportは兄弟要素で、scroll-timelineで名付けたタイムラインは既定ではその名前を宣言した要素の子孫からしか見えないため、兄弟側のバーからは参照できなかったのです。
timeline-scope で橋渡し
共通の祖先である.spにtimeline-scope: --sp-progressを書き足して初めて、その名前が兄弟側にも開かれ、実際のスクロール位置どおりにバーが伸びていくのを確認できました。インストール済みのChromeで実際にレンダリングし、00→12→22フレームを見比べると、バーが0%→約50%→100%と本当に埋まっていく様子まで実測できています。9個のフォルダーをこうして確かめ直してから圧縮したzipの合言葉はb2earrkfで、この段落の中にそのまま書いてあるのでここからコピーして使ってください。覆い層の上にさらに覆いが乗る衝突は、クッキー同意バナーのUIでも同じ重ね順の問題として現れ、規約の窓がバナーの下に埋もれます。
アクセシビリティ
css スライダー 自動再生はどうなるか
OSでモーションを減らす設定にしていると、07・08は自動で循環する動きが止まり、最初のカードだけが固定して表示されます。01・03・04・05・06・09はプレビュー用のループアニメーションだけが止まり、実際のスクロールとscroll-snapそのものは生きたまま残ります。
キーボードとラベル
03・04のドットとサムネイルにはaria-label="スライド1"のような番号付きラベルを付けており、02のラジオスライダーはネイティブのフォームコントロールなのでTabキーと矢印キーだけで選べます。
関連リンク
詳しい対応状況はMDNのprefers-reduced-motionドキュメントで確認できます。
スクロールを使わない別のカルーセルの作り方はCSSカルーセルまとめにもあり、CSSだけで動く他のアニメーションはCSSカテゴリーから探せます。
FAQ
css スライダー 作り方はラジオとスクロールスナップ、どちらがいいですか?
実際に指でドラッグする感触が欲しいならスクロールスナップ(01・03〜06・09)を、矢印やドットだけできっちり切り替えたいならラジオ方式(02)を使います。スクロールスナップはタッチ端末でそのまま指で送れるのが強みで、ラジオ方式はトラックの位置をラジオひとつで正確に固定できるのが強みです。
animation-timelineはすべてのブラウザで動きますか?
インストール済みのChromeで実際にレンダリングして値を測った結果なので、Chromium系の外までは保証できません。ただし止まるのは09番の進捗バーだけで、スクロールスナップ自体はそのまま動くため、スライドを送る操作には支障がありません。
React版でも仕組みは同じですか?
01・05・06・07・08・09はCSSモジュールに同じスクロールスナップ・@keyframes・animation-timelineをそのまま移すので原理は同じです。02(ラジオ)はuseStateひとつに置き換え、03・04(:target)は同じページに複数置いてもIDが衝突しないようscrollIntoViewに切り替えています。duration・easing・colorはどれもpropsで受け取ります。
scroll-snap-typeの正確な仕様はMDNのscroll-snap-typeドキュメントで、animation-timelineはMDNのanimation-timelineドキュメントで確認できます。送る二枚の写真を一枚の中で比べさせるなら、ビフォーアフターのスライダーがその取っ手を渡します。









