9 CSS Only Carousels You Can Copy-Paste
A carousel is a strip of photos that slides across the screen one at a time, usually with dots or arrows to flip through them.
01 css-only-carousel-snap-basic
02 css-only-carousel-dots-scroll-marker
03 css-only-carousel-arrows-scroll-button
05 css-only-carousel-autoplay-css
06 css-only-carousel-testimonial-slider
07 css-only-carousel-active-slide-scale
08 css-only-carousel-progress-marker
09 css-only-carousel-coverflow-3d
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Basic scroll-snap
- 02 ::scroll-marker dots
- 03 ::scroll-button arrows
- 04 Thumbnail markers
- 05 CSS-only autoplay
- 06 Testimonial slider
- 07 Active slide scale up
- 08 Progress marker sync
- 09 3D coverflow
These nine build a css carousel without javascript, ordered by what a carousel actually has to do rather than by popularity. 01 is the plain drag-and-snap that the rest build on. 02 through 04 add ways to see and jump straight to a specific slide — dots, arrows, thumbnails — still without a script. 05 and 06 hand control to the browser entirely and cycle on their own, no button required. 07 through 09 close with three ways of making the current slide stand out: a scale-up, a progress bar, and a full 3D tilt. To announce which slide is showing with a bar rather than dots, the scroll progress indicators build that gauge, and starting the first screen with a carousel is where the hero section animations line the pieces up. By the time the 3D coverflow of 09 arrives, the carousel is carrying a whole scene, not just a strip of images.
01Basic scroll-snap
Four cards that snap into place
Four photo cards sit side by side in a strip — the kind of gallery a shop's product page or a portfolio uses — and dragging or scrolling with a wheel snaps each one neatly into place at the edge of the frame.

<main class="stage">
<div class="cs is-demo">
<div class="cs__viewport">
<div class="cs__track">
<div class="cs__slide cs__slide--1"><span class="cs__cap">New Arrivals</span></div>
<div class="cs__slide cs__slide--2"><span class="cs__cap">Bestseller TOP</span></div>
<div class="cs__slide cs__slide--3"><span class="cs__cap">Free Shipping Event</span></div>
<div class="cs__slide cs__slide--4"><span class="cs__cap">Customer Reviews</span></div>
<div class="cs__slide cs__slide--1" aria-hidden="true"><span class="cs__cap">New Arrivals</span></div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base; // 데모 자동 재생 한 칸 이동 시간
$easing: $ease-spring;
$color: $subject-blue; // 슬라이드 1 색
@include stage($stage-ink);
.cs { width: min(400px, 92vw); overflow: hidden; border-radius: $r-card; }
// ── 실제 동작: overflow-x:auto + scroll-snap-type 만으로 끝난다 (0줄 JS) ──
// .cs__viewport 가 진짜 스크롤 상자. .cs__track 은 그 안의 콘텐츠(슬라이드 4장 = 400%)일 뿐이라
// 데모 루프가 track 을 옮겨도 실제 스크롤 위치·스냅은 그대로 살아있다
.cs__viewport {
overflow-x: auto;
scroll-snap-type: x mandatory;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
}
// ── 무한 루프 이음새: 첫 슬라이드를 트랙 끝에 한 장 더 복제해 둔다 ──
// 마지막→처음 되돌아감이 순간이동으로 보이지 않게, 예고편은 복제본까지 "다음 칸"처럼 한 칸 더 가고
// 100%→0% 재시작은 복제본이 1번과 똑같이 생겨 눈에 보이지 않는다 (끝→처음 텔레포트 금지, 사용자 2026-09-11)
.cs__track {
display: flex;
width: 500%;
}
.cs__slide {
flex: 0 0 20%;
scroll-snap-align: start;
aspect-ratio: 16 / 9;
margin-right: $sp-3;
border-radius: $r-card;
position: relative;
overflow: hidden;
display: grid;
place-items: end start;
box-sizing: border-box;
}
.cs__slide:last-child { margin-right: 0; }
// 플레이스홀더 "사진" — 해·산 모양을 순수 CSS 로 (외부 이미지 0)
.cs__slide::before {
content: ""; position: absolute; top: $sp-4; right: $sp-4;
width: $sp-10; height: $sp-10; border-radius: 50%;
background: rgba(255, 255, 255, .85);
}
.cs__slide::after {
content: ""; position: absolute; left: -8%; right: -8%; bottom: -6%; height: 52%;
background: rgba(23, 20, 26, .28);
clip-path: polygon(0% 100%, 20% 38%, 40% 64%, 58% 24%, 76% 58%, 100% 32%, 100% 100%);
}
.cs__slide--1 { background: linear-gradient(135deg, $color, #d6ddeb); }
.cs__slide--2 { background: linear-gradient(135deg, $stage-orange, #ebdbd6); }
.cs__slide--3 { background: linear-gradient(135deg, #c2ae82, $stage-yellow); }
.cs__slide--4 { background: linear-gradient(135deg, $subject-ink, #4a3f5c); }
.cs__cap {
position: relative; z-index: 1;
margin: $sp-4;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(23, 20, 26, .38);
color: $subject-cream;
font-weight: 600;
font-size: clamp(12px, 3.2vw, 14px);
line-height: 1;
font-family: inherit;
}
// ── 데모 루프: 클릭·드래그 전까지 한 칸씩 스스로 넘어간다 (진짜 스크롤은 그대로 살아있다) ──
// 마지막 칸은 1번의 복제본 — 거기까지 정상 속도로 가고 100%→0% 점프는 복제본=1번이라 안 보인다.
// 이동폭의 %는 트랙(500%) 기준이라 한 칸은 -20%, 슬라이드 사이 $sp-3 간격만큼 px 를 함께 뺀다
.cs.is-demo .cs__track { animation: cs-loop $dur-loop $easing infinite; }
@keyframes cs-loop {
0%, 8% { transform: translateX(0%); }
23%, 31% { transform: translateX(calc(-20% - #{$sp-3})); }
46%, 54% { transform: translateX(calc(-40% - #{$sp-3 * 2})); }
69%, 77% { transform: translateX(calc(-60% - #{$sp-3 * 3})); }
92%, 100% { transform: translateX(calc(-80% - #{$sp-3 * 4})); }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.cs.is-demo .cs__track { animation: none !important; }
.cs__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);
}
.cs {
width: min(400px, 92vw);
overflow: hidden;
border-radius: 16px;
}
.cs__viewport {
overflow-x: auto;
scroll-snap-type: x mandatory;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.cs__viewport::-webkit-scrollbar {
display: none;
}
.cs__track {
display: flex;
width: 500%;
}
.cs__slide {
flex: 0 0 20%;
scroll-snap-align: start;
aspect-ratio: 16/9;
margin-right: 12px;
border-radius: 16px;
position: relative;
overflow: hidden;
display: grid;
place-items: end start;
box-sizing: border-box;
}
.cs__slide:last-child {
margin-right: 0;
}
.cs__slide::before {
content: "";
position: absolute;
top: 16px;
right: 16px;
width: 40px;
height: 40px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.85);
}
.cs__slide::after {
content: "";
position: absolute;
left: -8%;
right: -8%;
bottom: -6%;
height: 52%;
background: rgba(23, 20, 26, 0.28);
clip-path: polygon(0% 100%, 20% 38%, 40% 64%, 58% 24%, 76% 58%, 100% 32%, 100% 100%);
}
.cs__slide--1 {
background: linear-gradient(135deg, #2f6df6, #d6ddeb);
}
.cs__slide--2 {
background: linear-gradient(135deg, #ff4d1f, #ebdbd6);
}
.cs__slide--3 {
background: linear-gradient(135deg, #c2ae82, #ffd23f);
}
.cs__slide--4 {
background: linear-gradient(135deg, #17141a, #4a3f5c);
}
.cs__cap {
position: relative;
z-index: 1;
margin: 16px;
padding: 4px 12px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.38);
color: #fff7e6;
font-weight: 600;
font-size: clamp(12px, 3.2vw, 14px);
line-height: 1;
font-family: inherit;
}
.cs.is-demo .cs__track {
animation: cs-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes cs-loop {
0%, 8% {
transform: translateX(0%);
}
23%, 31% {
transform: translateX(calc(-20% - 12px));
}
46%, 54% {
transform: translateX(calc(-40% - 24px));
}
69%, 77% {
transform: translateX(calc(-60% - 36px));
}
92%, 100% {
transform: translateX(calc(-80% - 48px));
}
}
@media (prefers-reduced-motion: reduce) {
.cs.is-demo .cs__track {
animation: none !important;
}
.cs__viewport {
scroll-behavior: auto;
}
}// 실제 동작은 overflow-x:auto + scroll-snap-type 만으로 이미 끝난다(0줄).
// 이 3줄은 미리보기가 스스로 도는 걸 멈추는 용도일 뿐 — 진짜 드래그·스크롤은 그대로 살아있다.
document.querySelector('.cs__viewport').addEventListener('pointerdown', function () {
document.querySelector('.cs').classList.remove('is-demo');
});import type { CSSProperties } from "react";
import styles from "./SnapBasic.module.scss";
const SLIDES = ["신상 컬렉션", "베스트셀러 TOP", "무료 배송 이벤트", "고객 후기"];
export type SnapBasicProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function SnapBasic({ duration = 300, easing = "cubic-bezier(.2,.8,.2,1)", color = "#2f6df6" }: SnapBasicProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.cs} style={vars}>
{/* 실제 동작은 overflow-x:auto + scroll-snap-type 만으로 끝난다 — 드래그해서 넘기면 된다 */}
<div className={styles.viewport}>
<div className={styles.track}>
{SLIDES.map((label, i) => (
<div key={label} className={`${styles.slide} ${styles["slide" + (i + 1)]}`}>
<span className={styles.cap}>{label}</span>
</div>
))}
</div>
</div>
</div>
);
}01 · Basic scroll-snap — Measured area 62.372% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password
Two properties, zero JavaScript
That scroll snap carousel css behavior comes from two properties alone, overflow-x: auto and scroll-snap-type: x mandatory, with zero JavaScript.
.cs__viewport {
overflow-x: auto;
scroll-snap-type: x mandatory;
}
.cs__track {
display: flex;
width: 400%;
}
.cs__slide {
flex: 0 0 25%;
scroll-snap-align: start;
}
02::scroll-marker dots
Only the current dot fills
Small dots sit below the slides, and only the dot for whichever photo is in view right now fills with color and grows slightly — a small orientation aid for an image gallery or an onboarding screen.

<main class="stage">
<div class="dm is-demo">
<div class="dm__viewport">
<div class="dm__track">
<div class="dm__slide dm__slide--1"><span class="dm__cap">Summer Lookbook</span></div>
<div class="dm__slide dm__slide--2"><span class="dm__cap">Wishlist</span></div>
<div class="dm__slide dm__slide--3"><span class="dm__cap">Flash Sale</span></div>
<div class="dm__slide dm__slide--1 dm__slide--clone" aria-hidden="true"><span class="dm__cap">Summer Lookbook</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);
.dm { width: min(340px, 82vw); overflow: hidden; border-radius: $r-card; }
// ── 실제 동작: scroll-marker-group + ::scroll-marker 가 점을 만들고 이동시킨다 (0줄 JS) ──
.dm__viewport {
overflow-x: auto;
scroll-snap-type: x mandatory;
scroll-marker-group: after;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
}
.dm__viewport::scroll-marker-group {
display: flex;
justify-content: center;
gap: $sp-2;
margin-top: $sp-2;
}
// ── 무한 루프 이음새: 1번을 트랙 끝에 복제(aria-hidden). 점은 복제분만 숨긴다 ──
.dm__track {
display: flex;
width: 400%;
}
.dm__slide {
flex: 0 0 25%;
scroll-snap-align: start;
aspect-ratio: 16 / 10;
margin-right: $sp-3;
border-radius: $r-card;
position: relative;
overflow: hidden;
display: grid;
place-items: end start;
box-sizing: border-box;
&::scroll-marker {
content: "";
width: $sp-2; height: $sp-2;
border-radius: 50%;
background: rgba(23, 20, 26, .28);
transition: background $duration $easing, transform $duration $easing;
}
&::scroll-marker:target-current {
background: $color;
transform: scale(1.35);
}
}
.dm__slide--clone::scroll-marker { display: none; }
.dm__slide:last-child { margin-right: 0; }
// 플레이스홀더 "사진"
.dm__slide::before {
content: ""; position: absolute; top: $sp-4; right: $sp-4;
width: $sp-10; height: $sp-10; border-radius: 50%;
background: rgba(255, 255, 255, .85);
}
.dm__slide::after {
content: ""; position: absolute; left: -8%; right: -8%; bottom: -6%; height: 52%;
background: rgba(23, 20, 26, .26);
clip-path: polygon(0% 100%, 20% 38%, 40% 64%, 58% 24%, 76% 58%, 100% 32%, 100% 100%);
}
.dm__slide--1 { background: linear-gradient(135deg, $subject-blue, #d6ddeb); }
.dm__slide--2 { background: linear-gradient(135deg, $color, #ebdbd6); }
.dm__slide--3 { background: linear-gradient(135deg, $subject-ink, #4a3f5c); }
.dm__cap {
position: relative; z-index: 1;
margin: $sp-4;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(23, 20, 26, .38);
color: $subject-cream;
font-weight: 600;
font-size: clamp(12px, 3.2vw, 14px);
line-height: 1;
font-family: inherit;
}
// ── 데모 루프: 트랙에 별도 transform 을 얹어 자동으로 넘어간다 (진짜 스크롤·점 클릭은 그대로) ──
// 마지막 칸은 1번의 복제본 — 그곳까지 정상 속도로 가고 100%→0% 점프는 복제본=1번이라 안 보인다 (텔레포트 금지)
.dm.is-demo .dm__track { animation: dm-loop $dur-loop $easing infinite; }
@keyframes dm-loop {
0%, 8% { transform: translateX(0%); }
31%, 39% { transform: translateX(calc(-25% - #{$sp-3})); }
62%, 70% { transform: translateX(calc(-50% - #{$sp-3 * 2})); }
92%, 100% { transform: translateX(calc(-75% - #{$sp-3 * 3})); }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.dm.is-demo .dm__track { animation: none !important; }
.dm__slide::scroll-marker { transition: none; }
}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);
}
.dm {
width: min(340px, 82vw);
overflow: hidden;
border-radius: 16px;
}
.dm__viewport {
overflow-x: auto;
scroll-snap-type: x mandatory;
scroll-marker-group: after;
scrollbar-width: none;
}
.dm__viewport::-webkit-scrollbar {
display: none;
}
.dm__viewport::scroll-marker-group {
display: flex;
justify-content: center;
gap: 8px;
margin-top: 8px;
}
.dm__track {
display: flex;
width: 400%;
}
.dm__slide {
flex: 0 0 25%;
scroll-snap-align: start;
aspect-ratio: 16/10;
margin-right: 12px;
border-radius: 16px;
position: relative;
overflow: hidden;
display: grid;
place-items: end start;
box-sizing: border-box;
}
.dm__slide::scroll-marker {
content: "";
width: 8px;
height: 8px;
border-radius: 50%;
background: rgba(23, 20, 26, 0.28);
transition: background 300ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.dm__slide::scroll-marker:target-current {
background: #ff4d1f;
transform: scale(1.35);
}
.dm__slide--clone::scroll-marker {
display: none;
}
.dm__slide:last-child {
margin-right: 0;
}
.dm__slide::before {
content: "";
position: absolute;
top: 16px;
right: 16px;
width: 40px;
height: 40px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.85);
}
.dm__slide::after {
content: "";
position: absolute;
left: -8%;
right: -8%;
bottom: -6%;
height: 52%;
background: rgba(23, 20, 26, 0.26);
clip-path: polygon(0% 100%, 20% 38%, 40% 64%, 58% 24%, 76% 58%, 100% 32%, 100% 100%);
}
.dm__slide--1 {
background: linear-gradient(135deg, #2f6df6, #d6ddeb);
}
.dm__slide--2 {
background: linear-gradient(135deg, #ff4d1f, #ebdbd6);
}
.dm__slide--3 {
background: linear-gradient(135deg, #17141a, #4a3f5c);
}
.dm__cap {
position: relative;
z-index: 1;
margin: 16px;
padding: 4px 12px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.38);
color: #fff7e6;
font-weight: 600;
font-size: clamp(12px, 3.2vw, 14px);
line-height: 1;
font-family: inherit;
}
.dm.is-demo .dm__track {
animation: dm-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes dm-loop {
0%, 8% {
transform: translateX(0%);
}
31%, 39% {
transform: translateX(calc(-25% - 12px));
}
62%, 70% {
transform: translateX(calc(-50% - 24px));
}
92%, 100% {
transform: translateX(calc(-75% - 36px));
}
}
@media (prefers-reduced-motion: reduce) {
.dm.is-demo .dm__track {
animation: none !important;
}
.dm__slide::scroll-marker {
transition: none;
}
}// 점(dot) 자체는 브라우저가 ::scroll-marker 로 자동 생성한다(0줄).
// 이 3줄은 미리보기 자동 재생만 멈춘다 — 실제로는 점을 눌러도, 슬라이드를 드래그해도 그대로 동작한다.
document.querySelector('.dm__viewport').addEventListener('pointerdown', function () {
document.querySelector('.dm').classList.remove('is-demo');
});import type { CSSProperties } from "react";
import styles from "./DotsScrollMarker.module.scss";
const SLIDES = ["여름 룩북", "위시리스트", "할인 특가"];
export type DotsScrollMarkerProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function DotsScrollMarker({ duration = 300, easing = "cubic-bezier(.2,.8,.2,1)", color = "#ff4d1f" }: DotsScrollMarkerProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.dm} style={vars}>
{/* 점(dot)은 브라우저가 ::scroll-marker 로 자동 생성한다 — React 상태 없이 CSS 만으로 */}
<div className={styles.viewport}>
<div className={styles.track}>
{SLIDES.map((label, i) => (
<div key={label} className={`${styles.slide} ${styles["slide" + (i + 1)]}`}>
<span className={styles.cap}>{label}</span>
</div>
))}
</div>
</div>
</div>
);
}02 · ::scroll-marker dots — Measured area 49.997% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where the dots come from
The browser draws every dot on its own: scroll-marker-group: after on the scroll container and a ::scroll-marker on each slide generate them, and :target-current styles whichever one is active.
.viewport {
scroll-marker-group: after;
}
.slide::scroll-marker {
content: "";
width: 8px;
height: 8px;
border-radius: 50%;
}
.slide::scroll-marker:target-current {
background: $color;
transform: scale(1.35);
}
03::scroll-button arrows
One photo per press
Prev and next arrow buttons flank the slides, and pressing one glides the strip over by exactly one photo — a natural fit for product-detail images or a banner someone flips through by hand.

<main class="stage">
<div class="ab is-demo">
<div class="ab__viewport">
<div class="ab__track">
<div class="ab__slide ab__slide--1"><span class="ab__cap">New Product</span></div>
<div class="ab__slide ab__slide--2"><span class="ab__cap">Limited Stock</span></div>
<div class="ab__slide ab__slide--3"><span class="ab__cap">Winter Essentials</span></div>
<div class="ab__slide ab__slide--1" aria-hidden="true"><span class="ab__cap">New Product</span></div>
<div class="ab__slide ab__slide--1" aria-hidden="true"><span class="ab__cap">New Product</span></div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-ink; // 화살표 버튼 색
@include stage($stage-orange);
.ab { width: min(360px, 84vw); position: relative; }
// ── 실제 동작: ::scroll-button() 이 이전/다음 버튼을 만들고, 끝에서 자동으로 비활성화한다 (0줄 JS) ──
.ab__viewport {
position: relative;
overflow-x: auto;
overflow-y: hidden;
scroll-snap-type: x mandatory;
border-radius: $r-card;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
&::scroll-button(left), &::scroll-button(right) {
position: absolute;
top: 50%;
z-index: 1;
width: $sp-8; height: $sp-8;
margin-top: calc(#{$sp-8} / -2);
border: 0;
border-radius: 50%;
background: rgba(255, 247, 230, .92);
color: $color;
font-weight: 700;
font-size: 16px;
line-height: 1;
font-family: inherit;
display: grid;
place-items: center;
cursor: pointer;
transition: opacity $duration $easing, transform $duration $easing;
}
&::scroll-button(left) { left: $sp-2; content: "‹"; }
&::scroll-button(right) { right: $sp-2; content: "›"; }
&::scroll-button(left):disabled, &::scroll-button(right):disabled {
opacity: 0; pointer-events: none;
}
}
// ── 무한 루프 이음새: 1번을 트랙 끝에 두 장 더 복제(aria-hidden) — 끝→처음 텔레포트 금지 ──
// 두 번째 복제는 화면에 보이지 않는 예비 칸: 트랙이 끝까지 밀려도 스크롤 넘침이 남아 있어
// ::scroll-button 의 활성/비활성이 루프 끝에서 깜빡이지 않는다
.ab__track {
display: flex;
width: 400%;
}
.ab__slide {
flex: 0 0 25%;
scroll-snap-align: start;
aspect-ratio: 16 / 10;
margin-right: $sp-3;
border-radius: $r-card;
position: relative;
overflow: hidden;
display: grid;
place-items: end start;
box-sizing: border-box;
}
.ab__slide:last-child { margin-right: 0; }
// 플레이스홀더 "사진"
.ab__slide::before {
content: ""; position: absolute; top: $sp-4; right: $sp-4;
width: $sp-10; height: $sp-10; border-radius: 50%;
background: rgba(255, 255, 255, .85);
}
.ab__slide::after {
content: ""; position: absolute; left: -8%; right: -8%; bottom: -6%; height: 52%;
background: rgba(23, 20, 26, .26);
clip-path: polygon(0% 100%, 20% 38%, 40% 64%, 58% 24%, 76% 58%, 100% 32%, 100% 100%);
}
.ab__slide--1 { background: linear-gradient(135deg, $stage-orange, #ebdbd6); }
.ab__slide--2 { background: linear-gradient(135deg, $subject-blue, #d6ddeb); }
.ab__slide--3 { background: linear-gradient(135deg, $subject-ink, #4a3f5c); }
.ab__cap {
position: relative; z-index: 1;
margin: $sp-4;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(23, 20, 26, .38);
color: $subject-cream;
font-weight: 600;
font-size: clamp(12px, 3.2vw, 14px);
line-height: 1;
font-family: inherit;
}
// ── 데모 루프: 트랙에 별도 transform 을 얹어 자동으로 넘어간다 (진짜 버튼 클릭·끝 비활성화는 그대로) ──
// 마지막 칸은 1번의 복제본 — 그곳까지 정상 속도로 가고 100%→0% 점프는 복제본=1번이라 안 보인다
.ab.is-demo .ab__track { animation: ab-loop $dur-loop $easing infinite; }
@keyframes ab-loop {
0%, 8% { transform: translateX(0%); }
31%, 39% { transform: translateX(calc(-25% - #{$sp-3})); }
62%, 70% { transform: translateX(calc(-50% - #{$sp-3 * 2})); }
92%, 100% { transform: translateX(calc(-75% - #{$sp-3 * 3})); }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.ab.is-demo .ab__track { animation: none !important; }
}@charset "UTF-8";
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);
}
.ab {
width: min(360px, 84vw);
position: relative;
}
.ab__viewport {
position: relative;
overflow-x: auto;
overflow-y: hidden;
scroll-snap-type: x mandatory;
border-radius: 16px;
scrollbar-width: none;
}
.ab__viewport::-webkit-scrollbar {
display: none;
}
.ab__viewport::scroll-button(left), .ab__viewport::scroll-button(right) {
position: absolute;
top: 50%;
z-index: 1;
width: 32px;
height: 32px;
margin-top: calc(32px / -2);
border: 0;
border-radius: 50%;
background: rgba(255, 247, 230, 0.92);
color: #17141a;
font-weight: 700;
font-size: 16px;
line-height: 1;
font-family: inherit;
display: grid;
place-items: center;
cursor: pointer;
transition: opacity 300ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ab__viewport::scroll-button(left) {
left: 8px;
content: "‹";
}
.ab__viewport::scroll-button(right) {
right: 8px;
content: "›";
}
.ab__viewport::scroll-button(left):disabled, .ab__viewport::scroll-button(right):disabled {
opacity: 0;
pointer-events: none;
}
.ab__track {
display: flex;
width: 400%;
}
.ab__slide {
flex: 0 0 25%;
scroll-snap-align: start;
aspect-ratio: 16/10;
margin-right: 12px;
border-radius: 16px;
position: relative;
overflow: hidden;
display: grid;
place-items: end start;
box-sizing: border-box;
}
.ab__slide:last-child {
margin-right: 0;
}
.ab__slide::before {
content: "";
position: absolute;
top: 16px;
right: 16px;
width: 40px;
height: 40px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.85);
}
.ab__slide::after {
content: "";
position: absolute;
left: -8%;
right: -8%;
bottom: -6%;
height: 52%;
background: rgba(23, 20, 26, 0.26);
clip-path: polygon(0% 100%, 20% 38%, 40% 64%, 58% 24%, 76% 58%, 100% 32%, 100% 100%);
}
.ab__slide--1 {
background: linear-gradient(135deg, #ff4d1f, #ebdbd6);
}
.ab__slide--2 {
background: linear-gradient(135deg, #2f6df6, #d6ddeb);
}
.ab__slide--3 {
background: linear-gradient(135deg, #17141a, #4a3f5c);
}
.ab__cap {
position: relative;
z-index: 1;
margin: 16px;
padding: 4px 12px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.38);
color: #fff7e6;
font-weight: 600;
font-size: clamp(12px, 3.2vw, 14px);
line-height: 1;
font-family: inherit;
}
.ab.is-demo .ab__track {
animation: ab-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes ab-loop {
0%, 8% {
transform: translateX(0%);
}
31%, 39% {
transform: translateX(calc(-25% - 12px));
}
62%, 70% {
transform: translateX(calc(-50% - 24px));
}
92%, 100% {
transform: translateX(calc(-75% - 36px));
}
}
@media (prefers-reduced-motion: reduce) {
.ab.is-demo .ab__track {
animation: none !important;
}
}// 화살표 버튼 자체는 브라우저가 ::scroll-button() 으로 자동 생성·비활성화한다(0줄).
// 이 3줄은 미리보기 자동 재생만 멈춘다 — 진짜 클릭은 그대로 동작한다.
document.querySelector('.ab__viewport').addEventListener('pointerdown', function () {
document.querySelector('.ab').classList.remove('is-demo');
});import type { CSSProperties } from "react";
import styles from "./ArrowsScrollButton.module.scss";
const SLIDES = ["신제품", "한정 수량", "겨울 준비물"];
export type ArrowsScrollButtonProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function ArrowsScrollButton({ duration = 300, easing = "cubic-bezier(.2,.8,.2,1)", color = "#17141a" }: ArrowsScrollButtonProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.ab} style={vars}>
{/* 화살표 버튼은 브라우저가 ::scroll-button() 으로 자동 생성·비활성화한다 */}
<div className={styles.viewport}>
<div className={styles.track}>
{SLIDES.map((label, i) => (
<div key={label} className={`${styles.slide} ${styles["slide" + (i + 1)]}`}>
<span className={styles.cap}>{label}</span>
</div>
))}
</div>
</div>
</div>
);
}03 · ::scroll-button arrows — Measured area 56.113% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where a css only carousel with arrows gets its buttons
Both buttons are generated by the browser from ::scroll-button(left) and ::scroll-button(right), and they dim automatically via :disabled once there's nothing left in that direction.
.viewport::scroll-button(left) {
content: "‹";
}
.viewport::scroll-button(right) {
content: "›";
}
.viewport::scroll-button(left):disabled,
.viewport::scroll-button(right):disabled {
opacity: 0;
pointer-events: none;
}
04Thumbnail markers
Thumbnails that match the main photo
Small square thumbnails line up below the main photo, and only the one matching whatever's showing gets an outline — handy for a product's color swatches or a multi-shot photoset.
<main class="stage">
<div class="tm is-demo">
<div class="tm__viewport">
<div class="tm__track">
<div class="tm__slide tm__slide--1" style="--thumb:#93a4ca"><span class="tm__cap">Blue</span></div>
<div class="tm__slide tm__slide--2" style="--thumb:#c89a8f"><span class="tm__cap">Orange</span></div>
<div class="tm__slide tm__slide--3" style="--thumb:#17141a"><span class="tm__cap">Black</span></div>
<div class="tm__slide tm__slide--1 tm__slide--clone" style="--thumb:#93a4ca" aria-hidden="true"><span class="tm__cap">Blue</span></div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-cream; // 활성 썸네일 테두리 색
@include stage($stage-paper);
.tm { width: min(340px, 82vw); overflow: hidden; }
// ── 실제 동작: ::scroll-marker 가 각 슬라이드의 --thumb 색을 물려받아 정사각 썸네일을 만든다 (0줄 JS) ──
.tm__viewport {
overflow-x: auto;
scroll-snap-type: x mandatory;
border-radius: $r-card;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
}
.tm__viewport::scroll-marker-group {
display: flex;
justify-content: center;
gap: $sp-2;
margin-top: $sp-3;
}
// ── 무한 루프 이음새: 1번을 트랙 끝에 복제(aria-hidden). 썸네일 마커는 복제분만 숨긴다 ──
.tm__track {
display: flex;
width: 400%;
}
.tm__slide {
flex: 0 0 25%;
scroll-snap-align: start;
aspect-ratio: 16 / 10;
margin-right: $sp-3;
border-radius: $r-card;
position: relative;
overflow: hidden;
display: grid;
place-items: end start;
box-sizing: border-box;
background: linear-gradient(135deg, var(--thumb), color-mix(in srgb, var(--thumb) 55%, white));
&::scroll-marker {
content: "";
width: $sp-4; height: $sp-4;
border-radius: $r-xs;
background: var(--thumb);
border: 2px solid transparent;
box-sizing: border-box;
transition: border-color $duration $easing, transform $duration $easing;
}
&::scroll-marker:target-current {
border-color: $color;
transform: scale(1.12);
}
}
.tm__slide--clone::scroll-marker { display: none; }
.tm__slide:last-child { margin-right: 0; }
.tm__slide::before {
content: ""; position: absolute; top: $sp-4; right: $sp-4;
width: $sp-8; height: $sp-8; border-radius: 50%;
background: rgba(255, 255, 255, .78);
}
.tm__slide::after {
content: ""; position: absolute; left: -8%; right: -8%; bottom: -6%; height: 48%;
background: rgba(23, 20, 26, .22);
clip-path: polygon(0% 100%, 20% 40%, 40% 66%, 58% 26%, 76% 60%, 100% 34%, 100% 100%);
}
.tm__cap {
position: relative; z-index: 1;
margin: $sp-3;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(23, 20, 26, .38);
color: $subject-cream;
font-weight: 600;
font-size: clamp(12px, 3.2vw, 13px);
line-height: 1;
font-family: inherit;
}
// ── 데모 루프: 트랙에 별도 transform 을 얹어 자동으로 넘어간다 (진짜 스크롤·썸네일 클릭은 그대로) ──
// 마지막 칸은 1번의 복제본 — 그곳까지 정상 속도로 가고 100%→0% 점프는 복제본=1번이라 안 보인다 (텔레포트 금지)
.tm.is-demo .tm__track { animation: tm-loop $dur-loop $easing infinite; }
@keyframes tm-loop {
0%, 8% { transform: translateX(0%); }
31%, 39% { transform: translateX(calc(-25% - #{$sp-3})); }
62%, 70% { transform: translateX(calc(-50% - #{$sp-3 * 2})); }
92%, 100% { transform: translateX(calc(-75% - #{$sp-3 * 3})); }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.tm.is-demo .tm__track { animation: none !important; }
.tm__slide::scroll-marker { transition: none; }
}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(340px, 82vw);
overflow: hidden;
}
.tm__viewport {
overflow-x: auto;
scroll-snap-type: x mandatory;
border-radius: 16px;
scrollbar-width: none;
}
.tm__viewport::-webkit-scrollbar {
display: none;
}
.tm__viewport::scroll-marker-group {
display: flex;
justify-content: center;
gap: 8px;
margin-top: 12px;
}
.tm__track {
display: flex;
width: 400%;
}
.tm__slide {
flex: 0 0 25%;
scroll-snap-align: start;
aspect-ratio: 16/10;
margin-right: 12px;
border-radius: 16px;
position: relative;
overflow: hidden;
display: grid;
place-items: end start;
box-sizing: border-box;
background: linear-gradient(135deg, var(--thumb), color-mix(in srgb, var(--thumb) 55%, white));
}
.tm__slide::scroll-marker {
content: "";
width: 16px;
height: 16px;
border-radius: 4px;
background: var(--thumb);
border: 2px solid transparent;
box-sizing: border-box;
transition: border-color 300ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.tm__slide::scroll-marker:target-current {
border-color: #fff7e6;
transform: scale(1.12);
}
.tm__slide--clone::scroll-marker {
display: none;
}
.tm__slide:last-child {
margin-right: 0;
}
.tm__slide::before {
content: "";
position: absolute;
top: 16px;
right: 16px;
width: 32px;
height: 32px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.78);
}
.tm__slide::after {
content: "";
position: absolute;
left: -8%;
right: -8%;
bottom: -6%;
height: 48%;
background: rgba(23, 20, 26, 0.22);
clip-path: polygon(0% 100%, 20% 40%, 40% 66%, 58% 26%, 76% 60%, 100% 34%, 100% 100%);
}
.tm__cap {
position: relative;
z-index: 1;
margin: 12px;
padding: 4px 12px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.38);
color: #fff7e6;
font-weight: 600;
font-size: clamp(12px, 3.2vw, 13px);
line-height: 1;
font-family: inherit;
}
.tm.is-demo .tm__track {
animation: tm-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes tm-loop {
0%, 8% {
transform: translateX(0%);
}
31%, 39% {
transform: translateX(calc(-25% - 12px));
}
62%, 70% {
transform: translateX(calc(-50% - 24px));
}
92%, 100% {
transform: translateX(calc(-75% - 36px));
}
}
@media (prefers-reduced-motion: reduce) {
.tm.is-demo .tm__track {
animation: none !important;
}
.tm__slide::scroll-marker {
transition: none;
}
}// 썸네일도 브라우저가 ::scroll-marker 로 자동 생성한다(0줄). 색은 슬라이드의 --thumb 변수를 물려받는다.
// 이 3줄은 미리보기 자동 재생만 멈춘다.
document.querySelector('.tm__viewport').addEventListener('pointerdown', function () {
document.querySelector('.tm').classList.remove('is-demo');
});import type { CSSProperties } from "react";
import styles from "./ThumbnailsMarkers.module.scss";
const SLIDES: { label: string; thumb: string }[] = [
{ label: "블루", thumb: "#2f6df6" },
{ label: "오렌지", thumb: "#ff4d1f" },
{ label: "블랙", thumb: "#17141a" },
];
export type ThumbnailsMarkersProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string; "--thumb"?: string };
export default function ThumbnailsMarkers({ duration = 300, easing = "cubic-bezier(.2,.8,.2,1)", color = "#fff7e6" }: ThumbnailsMarkersProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.tm} style={vars}>
{/* 썸네일도 ::scroll-marker 하나 — 색은 슬라이드의 --thumb 변수를 물려받는다 */}
<div className={styles.viewport}>
<div className={styles.track}>
{SLIDES.map((s) => (
<div key={s.label} className={styles.slide} style={{ "--thumb": s.thumb } as Vars}>
<span className={styles.cap}>{s.label}</span>
</div>
))}
</div>
</div>
</div>
);
}04 · Thumbnail markers — Measured area 49.928% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password
Inheriting the color
The thumbnails are ::scroll-marker again, and each one inherits its color straight from a --thumb custom property set on the matching slide.
.slide {
background: var(--thumb);
}
.slide::scroll-marker {
content: "";
background: var(--thumb);
border: 2px solid transparent;
}
.slide::scroll-marker:target-current {
border-color: $color;
}
05CSS-only autoplay
Moving on their own every few seconds
Nobody has to press anything — the photos move to the next one on their own every few seconds, the way a hero banner or an event slide usually runs, and hovering the strip pauses it.

<main class="stage">
<div class="ap">
<div class="ap__track">
<div class="ap__slide ap__slide--1"><span class="ap__cap">Happening Now</span></div>
<div class="ap__slide ap__slide--2"><span class="ap__cap">New Member Perks</span></div>
<div class="ap__slide ap__slide--3"><span class="ap__cap">Winter Season Off</span></div>
<div class="ap__slide ap__slide--4"><span class="ap__cap">Member-Only Coupon</span></div>
<div class="ap__slide ap__slide--1" aria-hidden="true"><span class="ap__cap">Happening Now</span></div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 한 바퀴(슬라이드 4장) 도는 총 시간
$easing: $ease-out;
$color: $subject-blue;
@include stage($stage-ink);
.ap { width: min(400px, 92vw); overflow: hidden; border-radius: $r-card; }
// ── 실제 동작: 트랙 하나가 @keyframes 로 무한 반복 — 0줄 JS, 클릭할 것도 없다 ──
// 무한 루프 이음새: 1번을 트랙 끝에 복제(aria-hidden)해 그 복제본까지 정상 속도로 가고
// 100%→0% 재시작 점프는 복제본이 1번과 같아 안 보인다 (끝→처음 텔레포트 금지, 사용자 2026-09-11)
.ap__track {
display: flex;
width: 500%;
animation: ap-cycle $duration $easing infinite;
}
// 마우스를 올리면 잠깐 멈춰 원하는 사진을 볼 수 있다 (호버 == 진짜 사용자 조작, 여전히 0줄 JS)
.ap:hover .ap__track { animation-play-state: paused; }
.ap__slide {
flex: 0 0 20%;
aspect-ratio: 16 / 9;
margin-right: $sp-3;
border-radius: $r-card;
position: relative;
overflow: hidden;
display: grid;
place-items: end start;
box-sizing: border-box;
}
.ap__slide:last-child { margin-right: 0; }
.ap__slide::before {
content: ""; position: absolute; top: $sp-4; right: $sp-4;
width: $sp-10; height: $sp-10; border-radius: 50%;
background: rgba(255, 255, 255, .85);
}
.ap__slide::after {
content: ""; position: absolute; left: -8%; right: -8%; bottom: -6%; height: 52%;
background: rgba(23, 20, 26, .28);
clip-path: polygon(0% 100%, 20% 38%, 40% 64%, 58% 24%, 76% 58%, 100% 32%, 100% 100%);
}
.ap__slide--1 { background: linear-gradient(135deg, $color, #d6ddeb); }
.ap__slide--2 { background: linear-gradient(135deg, $stage-orange, #ebdbd6); }
.ap__slide--3 { background: linear-gradient(135deg, #c2ae82, $stage-yellow); }
.ap__slide--4 { background: linear-gradient(135deg, $subject-ink, #4a3f5c); }
.ap__cap {
position: relative; z-index: 1;
margin: $sp-4;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(23, 20, 26, .38);
color: $subject-cream;
font-weight: 600;
font-size: clamp(12px, 3.2vw, 14px);
line-height: 1;
font-family: inherit;
}
// 한 칸 = 트랙(500%)의 -20% + 슬라이드 간 $sp-3 간격. 마지막(5번째) 칸은 1번의 복제본
@keyframes ap-cycle {
0%, 8% { transform: translateX(0%); }
23%, 31% { transform: translateX(calc(-20% - #{$sp-3})); }
46%, 54% { transform: translateX(calc(-40% - #{$sp-3 * 2})); }
69%, 77% { transform: translateX(calc(-60% - #{$sp-3 * 3})); }
92%, 100% { transform: translateX(calc(-80% - #{$sp-3 * 4})); }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.ap__track { animation: none !important; transform: translateX(0%); }
}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);
}
.ap {
width: min(400px, 92vw);
overflow: hidden;
border-radius: 16px;
}
.ap__track {
display: flex;
width: 500%;
animation: ap-cycle 2s ease-out infinite;
}
.ap:hover .ap__track {
animation-play-state: paused;
}
.ap__slide {
flex: 0 0 20%;
aspect-ratio: 16/9;
margin-right: 12px;
border-radius: 16px;
position: relative;
overflow: hidden;
display: grid;
place-items: end start;
box-sizing: border-box;
}
.ap__slide:last-child {
margin-right: 0;
}
.ap__slide::before {
content: "";
position: absolute;
top: 16px;
right: 16px;
width: 40px;
height: 40px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.85);
}
.ap__slide::after {
content: "";
position: absolute;
left: -8%;
right: -8%;
bottom: -6%;
height: 52%;
background: rgba(23, 20, 26, 0.28);
clip-path: polygon(0% 100%, 20% 38%, 40% 64%, 58% 24%, 76% 58%, 100% 32%, 100% 100%);
}
.ap__slide--1 {
background: linear-gradient(135deg, #2f6df6, #d6ddeb);
}
.ap__slide--2 {
background: linear-gradient(135deg, #ff4d1f, #ebdbd6);
}
.ap__slide--3 {
background: linear-gradient(135deg, #c2ae82, #ffd23f);
}
.ap__slide--4 {
background: linear-gradient(135deg, #17141a, #4a3f5c);
}
.ap__cap {
position: relative;
z-index: 1;
margin: 16px;
padding: 4px 12px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.38);
color: #fff7e6;
font-weight: 600;
font-size: clamp(12px, 3.2vw, 14px);
line-height: 1;
font-family: inherit;
}
@keyframes ap-cycle {
0%, 8% {
transform: translateX(0%);
}
23%, 31% {
transform: translateX(calc(-20% - 12px));
}
46%, 54% {
transform: translateX(calc(-40% - 24px));
}
69%, 77% {
transform: translateX(calc(-60% - 36px));
}
92%, 100% {
transform: translateX(calc(-80% - 48px));
}
}
@media (prefers-reduced-motion: reduce) {
.ap__track {
animation: none !important;
transform: translateX(0%);
}
}import type { CSSProperties } from "react";
import styles from "./AutoplayCss.module.scss";
const SLIDES = ["지금 진행 중", "신규 가입 혜택", "겨울 시즌 오프", "회원 전용 쿠폰"];
export type AutoplayCssProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function AutoplayCss({ duration = 2000, easing = "ease-out", color = "#2f6df6" }: AutoplayCssProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.ap} style={vars}>
{/* 아무것도 누르지 않아도 트랙 하나가 @keyframes 로 무한 반복 — 0줄 JS */}
{/* 끝에는 첫 장의 복제본이 한 장 더 — 마지막→처음 되돌아감이 순간이동으로 보이지 않는다 (텔레포트 금지) */}
<div className={styles.track}>
{SLIDES.map((label, i) => (
<div key={label} className={`${styles.slide} ${styles["slide" + (i + 1)]}`}>
<span className={styles.cap}>{label}</span>
</div>
))}
<div className={`${styles.slide} ${styles.slide1}`} aria-hidden="true">
<span className={styles.cap}>{SLIDES[0]}</span>
</div>
</div>
</div>
);
}05 · CSS-only autoplay — Measured area 62.373% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password
Hover is the whole pause mechanism
One track plays a single @keyframes loop set to infinite, and animation-play-state: paused on :hover is the entire pause mechanism.
.track {
display: flex;
width: 400%;
animation: cycle 8s ease-out infinite;
}
.ap:hover .track {
animation-play-state: paused;
}
@keyframes cycle {
0%, 17% { transform: translateX(0%); }
25%, 42% { transform: translateX(-25%); }
50%, 67% { transform: translateX(-50%); }
75%, 92% { transform: translateX(-75%); }
100% { transform: translateX(0%); }
}
06Testimonial slider
Cards fading in the same spot
Customer review cards fade in and out on their own, one after another, and because every card sits stacked in the exact same spot, a longer quote never pushes the layout taller.

<main class="stage">
<div class="ts">
<div class="ts__stack">
<figure class="ts__card ts__card--1">
<span class="ts__avatar">M</span>
<blockquote class="ts__quote">"Delivery arrived the very next day. Packaging was careful too."</blockquote>
<figcaption class="ts__name">Minji Kim</figcaption>
</figure>
<figure class="ts__card ts__card--2">
<span class="ts__avatar">S</span>
<blockquote class="ts__quote">"The color matched the photos exactly. I'll definitely buy again."</blockquote>
<figcaption class="ts__name">Seojun Lee</figcaption>
</figure>
<figure class="ts__card ts__card--3">
<span class="ts__avatar">H</span>
<blockquote class="ts__quote">"Support replied fast and kindly — it made me trust the shop."</blockquote>
<figcaption class="ts__name">Haeun Park</figcaption>
</figure>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 카드 한 장이 머무는 박자의 기준
$easing: $ease-out;
$color: $subject-blue;
@include stage($stage-paper);
.ts { width: min(340px, 84vw); }
// ── 실제 동작: 카드 3장을 같은 grid 칸에 겹치고 opacity 만 @keyframes 로 순환 — 0줄 JS ──
.ts__stack {
position: relative;
display: grid;
border-radius: $r-card;
background: $subject-ink;
padding: $sp-4 $sp-5;
min-height: 128px;
}
.ts__card {
grid-area: 1 / 1;
margin: 0;
opacity: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: $sp-2;
}
.ts__avatar {
width: $sp-8; height: $sp-8;
border-radius: 50%;
display: grid; place-items: center;
background: $color;
color: $subject-cream;
font-weight: 700;
font-size: 14px;
line-height: 1;
font-family: inherit;
}
.ts__quote {
margin: 0;
color: $subject-cream;
font-weight: 400;
font-size: clamp(13px, 3.6vw, 15px);
line-height: 1.5;
font-family: inherit;
}
.ts__name {
color: rgba(255, 247, 230, .6);
font-weight: 600;
font-size: clamp(11px, 3vw, 12px);
line-height: 1;
font-family: inherit;
}
.ts__card--1 { animation: ts-1 $duration $easing infinite; }
.ts__card--2 { animation: ts-2 $duration $easing infinite; }
.ts__card--3 { animation: ts-3 $duration $easing infinite; }
// 교차 페이드 창을 12% 로 넓게 — 마지막→처음 순간에 배경이 뚫리는 팝 없이
// 3번의 페이드아웃(88→100%)과 1번의 페이드인(0→12%)이 릴레이로 이어진다 (텔레포트 금지)
@keyframes ts-1 { 0% { opacity: 0; } 12%, 25% { opacity: 1; } 37%, 100% { opacity: 0; } }
@keyframes ts-2 { 0%, 25% { opacity: 0; } 37%, 58% { opacity: 1; } 70%, 100% { opacity: 0; } }
@keyframes ts-3 { 0%, 58% { opacity: 0; } 70%, 88% { opacity: 1; } 100% { opacity: 0; } }
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.ts__card { animation: none !important; }
.ts__card--1 { opacity: 1; }
.ts__card--2, .ts__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);
}
.ts {
width: min(340px, 84vw);
}
.ts__stack {
position: relative;
display: grid;
border-radius: 16px;
background: #17141a;
padding: 16px 20px;
min-height: 128px;
}
.ts__card {
grid-area: 1/1;
margin: 0;
opacity: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.ts__avatar {
width: 32px;
height: 32px;
border-radius: 50%;
display: grid;
place-items: center;
background: #2f6df6;
color: #fff7e6;
font-weight: 700;
font-size: 14px;
line-height: 1;
font-family: inherit;
}
.ts__quote {
margin: 0;
color: #fff7e6;
font-weight: 400;
font-size: clamp(13px, 3.6vw, 15px);
line-height: 1.5;
font-family: inherit;
}
.ts__name {
color: rgba(255, 247, 230, 0.6);
font-weight: 600;
font-size: clamp(11px, 3vw, 12px);
line-height: 1;
font-family: inherit;
}
.ts__card--1 {
animation: ts-1 2s ease-out infinite;
}
.ts__card--2 {
animation: ts-2 2s ease-out infinite;
}
.ts__card--3 {
animation: ts-3 2s ease-out infinite;
}
@keyframes ts-1 {
0% {
opacity: 0;
}
12%, 25% {
opacity: 1;
}
37%, 100% {
opacity: 0;
}
}
@keyframes ts-2 {
0%, 25% {
opacity: 0;
}
37%, 58% {
opacity: 1;
}
70%, 100% {
opacity: 0;
}
}
@keyframes ts-3 {
0%, 58% {
opacity: 0;
}
70%, 88% {
opacity: 1;
}
100% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.ts__card {
animation: none !important;
}
.ts__card--1 {
opacity: 1;
}
.ts__card--2, .ts__card--3 {
opacity: 0;
}
}import type { CSSProperties } from "react";
import styles from "./TestimonialSlider.module.scss";
const REVIEWS = [
{ avatar: "민", quote: "배송이 다음 날 바로 왔어요. 포장도 꼼꼼했습니다.", name: "김민지" },
{ avatar: "서", quote: "색상이 사진이랑 똑같아서 놀랐어요. 재구매 예정.", name: "이서준" },
{ avatar: "하", quote: "고객센터 응대가 빠르고 친절해서 믿음이 갑니다.", name: "박하은" },
];
export type TestimonialSliderProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function TestimonialSlider({ duration = 2000, easing = "ease-out", color = "#2f6df6" }: TestimonialSliderProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.ts} style={vars}>
{/* 카드 3장이 같은 grid 칸에 겹쳐 opacity 로만 순환 — 0줄 JS */}
<div className={styles.stack}>
{REVIEWS.map((r, i) => (
<figure key={r.name} className={`${styles.card} ${styles["card" + (i + 1)]}`}>
<span className={styles.avatar}>{r.avatar}</span>
<blockquote className={styles.quote}>"{r.quote}"</blockquote>
<figcaption className={styles.name}>{r.name}</figcaption>
</figure>
))}
</div>
</div>
);
}06 · Testimonial slider — Measured area 3.304% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password
Why a longer quote never pushes
Three cards share one grid cell through grid-area: 1 / 1, each running its own phase-shifted @keyframes that only touches opacity.
.stack {
display: grid;
}
.card {
grid-area: 1 / 1;
opacity: 0;
}
.card1 { animation: c1 8s ease-out infinite; }
@keyframes c1 {
0%, 4% { opacity: 1; }
29%, 100% { opacity: 0; }
}
07Active slide scale-up
Center sharp, neighbors faded
The photo in the center sits noticeably bigger and fully sharp while its neighbors shrink and fade, making the current pick obvious at a glance — click any photo and it takes the center spot.

<main class="stage">
<div class="as is-demo">
<input type="radio" name="as-tab" id="as-1" class="as__radio" checked>
<input type="radio" name="as-tab" id="as-2" class="as__radio">
<input type="radio" name="as-tab" id="as-3" class="as__radio">
<div class="as__track">
<label class="as__slide as__slide--1" for="as-1" aria-label="Slide 1"></label>
<label class="as__slide as__slide--2" for="as-2" aria-label="Slide 2"></label>
<label class="as__slide as__slide--3" for="as-3" aria-label="Slide 3"></label>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base;
$easing: $ease-spring;
$color: $stage-orange;
@include stage($stage-ink);
.as { width: min(400px, 92vw); }
.as__radio { position: absolute; opacity: 0; pointer-events: none; }
.as__track {
display: flex;
align-items: center;
justify-content: center;
gap: $sp-3;
}
.as__slide {
flex: 0 0 30%;
aspect-ratio: 3 / 4;
border-radius: $r-card;
position: relative;
overflow: hidden;
cursor: pointer;
transform: scale(.82);
opacity: .5;
transition: transform $duration $easing, opacity $duration $easing;
z-index: 0;
}
.as__slide--1 { background: linear-gradient(160deg, $subject-blue, #d6ddeb); }
.as__slide--2 { background: linear-gradient(160deg, $color, #ebdbd6); }
.as__slide--3 { background: linear-gradient(160deg, #c2ae82, $stage-yellow); }
.as__slide::before {
content: ""; position: absolute; top: $sp-2; right: $sp-2;
width: $sp-6; height: $sp-6; border-radius: 50%;
background: rgba(255, 255, 255, .85);
}
.as__slide::after {
content: ""; position: absolute; left: -10%; right: -10%; bottom: -6%; height: 46%;
background: rgba(23, 20, 26, .26);
clip-path: polygon(0% 100%, 22% 40%, 42% 66%, 60% 26%, 78% 60%, 100% 34%, 100% 100%);
}
// ── 실제 동작: 고른 라디오의 슬라이드만 커지고 진해진다. 나머지는 작고 흐리다 ──
#as-1:checked ~ .as__track .as__slide--1,
#as-2:checked ~ .as__track .as__slide--2,
#as-3:checked ~ .as__track .as__slide--3 {
transform: scale(1.08);
opacity: 1;
z-index: 1;
}
// ── 데모 루프: 클릭 전까지 라디오가 스스로 1→2→3→1 순으로 커진다 (라디오 상태는 그대로 1번) ──
.as.is-demo .as__slide--1 { animation: as-1 $dur-loop $easing infinite; }
.as.is-demo .as__slide--2 { animation: as-2 $dur-loop $easing infinite; }
.as.is-demo .as__slide--3 { animation: as-3 $dur-loop $easing infinite; }
// 1번은 0%에서 작은 상태로 시작해 0→4% 동안 커진다(3번이 96→100%에서 작아지는 것과 이어진다) —
// 루프 경계에서 1번이 순간이동으로 팝 커지던 티를 없앤다 (끝→처음 텔레포트 금지)
@keyframes as-1 {
0% { transform: scale(.82); opacity: .5; }
4%, 29% { transform: scale(1.08); opacity: 1; }
54%, 100% { transform: scale(.82); opacity: .5; }
}
@keyframes as-2 {
0%, 29% { transform: scale(.82); opacity: .5; }
33%, 62% { transform: scale(1.08); opacity: 1; }
66%, 100% { transform: scale(.82); opacity: .5; }
}
@keyframes as-3 {
0%, 62% { transform: scale(.82); opacity: .5; }
66%, 96% { transform: scale(1.08); opacity: 1; }
100% { transform: scale(.82); opacity: .5; }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.as__slide { transition: none; }
.as.is-demo .as__slide--1,
.as.is-demo .as__slide--2,
.as.is-demo .as__slide--3 { 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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.as {
width: min(400px, 92vw);
}
.as__radio {
position: absolute;
opacity: 0;
pointer-events: none;
}
.as__track {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
}
.as__slide {
flex: 0 0 30%;
aspect-ratio: 3/4;
border-radius: 16px;
position: relative;
overflow: hidden;
cursor: pointer;
transform: scale(0.82);
opacity: 0.5;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
z-index: 0;
}
.as__slide--1 {
background: linear-gradient(160deg, #2f6df6, #d6ddeb);
}
.as__slide--2 {
background: linear-gradient(160deg, #ff4d1f, #ebdbd6);
}
.as__slide--3 {
background: linear-gradient(160deg, #c2ae82, #ffd23f);
}
.as__slide::before {
content: "";
position: absolute;
top: 8px;
right: 8px;
width: 24px;
height: 24px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.85);
}
.as__slide::after {
content: "";
position: absolute;
left: -10%;
right: -10%;
bottom: -6%;
height: 46%;
background: rgba(23, 20, 26, 0.26);
clip-path: polygon(0% 100%, 22% 40%, 42% 66%, 60% 26%, 78% 60%, 100% 34%, 100% 100%);
}
#as-1:checked ~ .as__track .as__slide--1,
#as-2:checked ~ .as__track .as__slide--2,
#as-3:checked ~ .as__track .as__slide--3 {
transform: scale(1.08);
opacity: 1;
z-index: 1;
}
.as.is-demo .as__slide--1 {
animation: as-1 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.as.is-demo .as__slide--2 {
animation: as-2 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.as.is-demo .as__slide--3 {
animation: as-3 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes as-1 {
0% {
transform: scale(0.82);
opacity: 0.5;
}
4%, 29% {
transform: scale(1.08);
opacity: 1;
}
54%, 100% {
transform: scale(0.82);
opacity: 0.5;
}
}
@keyframes as-2 {
0%, 29% {
transform: scale(0.82);
opacity: 0.5;
}
33%, 62% {
transform: scale(1.08);
opacity: 1;
}
66%, 100% {
transform: scale(0.82);
opacity: 0.5;
}
}
@keyframes as-3 {
0%, 62% {
transform: scale(0.82);
opacity: 0.5;
}
66%, 96% {
transform: scale(1.08);
opacity: 1;
}
100% {
transform: scale(0.82);
opacity: 0.5;
}
}
@media (prefers-reduced-motion: reduce) {
.as__slide {
transition: none;
}
.as.is-demo .as__slide--1,
.as.is-demo .as__slide--2,
.as.is-demo .as__slide--3 {
animation: none !important;
}
}// 실제 동작은 :checked 만으로 끝난다(0줄). 이 3줄은 미리보기 자동 재생만 멈춘다.
document.querySelectorAll('.as__radio').forEach(function (r) {
r.addEventListener('pointerdown', function () { document.querySelector('.as').classList.remove('is-demo'); });
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ActiveSlideScale.module.scss";
export type ActiveSlideScaleProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function ActiveSlideScale({ duration = 300, easing = "cubic-bezier(.2,.8,.2,1)", color = "#ff4d1f" }: ActiveSlideScaleProps) {
const [active, setActive] = useState(0);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.as} style={vars}>
<div className={styles.track}>
{[0, 1, 2].map((i) => (
<button
key={i}
type="button"
aria-label={`슬라이드 ${i + 1}`}
className={`${styles.slide} ${styles["slide" + (i + 1)]} ${i === active ? styles.isActive : ""}`}
onClick={() => setActive(i)}
/>
))}
</div>
</div>
);
}07 · Active slide scale up — Measured area 46.955% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password
Three radios doing the work
Three native radio inputs drive it: whichever slide's :checked matches gets scale(1.08) and full opacity, and the rest sit at scale(.82) and half opacity.
.slide {
transform: scale(.82);
opacity: .5;
transition: transform .3s, opacity .3s;
}
#as-2:checked ~ .as__track .as__slide--2 {
transform: scale(1.08);
opacity: 1;
}
08Progress marker sync
A bar that fills with every photo
A thin bar above the slides fills a little further with every photo, so there's no need to count which one is showing, and clicking straight on the bar jumps to that spot.

<main class="stage">
<div class="pm is-demo">
<input type="radio" name="pm-tab" id="pm-1" class="pm__radio" checked>
<input type="radio" name="pm-tab" id="pm-2" class="pm__radio">
<input type="radio" name="pm-tab" id="pm-3" class="pm__radio">
<div class="pm__bar">
<span class="pm__fill"></span>
<label class="pm__seg" for="pm-1" aria-label="Go to slide 1"></label>
<label class="pm__seg" for="pm-2" aria-label="Go to slide 2"></label>
<label class="pm__seg" for="pm-3" aria-label="Go to slide 3"></label>
</div>
<div class="pm__viewport">
<div class="pm__track">
<div class="pm__slide pm__slide--1"><span class="pm__cap">01 · Brand Story</span></div>
<div class="pm__slide pm__slide--2"><span class="pm__cap">02 · This Season's Collection</span></div>
<div class="pm__slide pm__slide--3"><span class="pm__cap">03 · Store Info</span></div>
<div class="pm__slide pm__slide--1" aria-hidden="true"><span class="pm__cap">01 · Brand Story</span></div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-blue; // 진행 바 색
@include stage($stage-yellow);
.pm { width: min(360px, 84vw); }
.pm__radio { position: absolute; opacity: 0; pointer-events: none; }
.pm__bar {
position: relative;
display: flex;
height: 4px;
border-radius: $r-pill;
background: rgba(23, 20, 26, .16);
overflow: hidden;
margin-bottom: $sp-3;
}
.pm__fill {
position: absolute;
left: 0; top: 0;
display: block;
height: 100%;
width: calc(100% / 3);
border-radius: $r-pill;
background: $color;
transition: width $duration $easing;
}
// 진행 바 위에 놓인 투명 클릭 영역 — 원하는 번호를 바로 누를 수 있다 (0줄 JS)
.pm__seg {
position: relative;
z-index: 1;
flex: 1;
cursor: pointer;
}
#pm-2:checked ~ .pm__bar .pm__fill { width: calc(200% / 3); }
#pm-3:checked ~ .pm__bar .pm__fill { width: 100%; }
// ── 실제 동작: 트랙이 :checked 만으로 한 칸씩 옮겨가고, 진행 바는 같은 신호로 채워진다 ──
// 트랙 끝에는 1번의 복제본(aria-hidden)이 한 장 더 — 예고편 루프가 그곳까지 가고 되돌아간다 (텔레포트 금지)
.pm__viewport { overflow: hidden; border-radius: $r-card; }
.pm__track {
display: flex;
width: 400%;
transform: translateX(0%);
transition: transform $duration $easing;
}
#pm-2:checked ~ .pm__viewport .pm__track { transform: translateX(calc(-25% - #{$sp-3})); }
#pm-3:checked ~ .pm__viewport .pm__track { transform: translateX(calc(-50% - #{$sp-3 * 2})); }
.pm__slide {
flex: 0 0 25%;
aspect-ratio: 16 / 9;
margin-right: $sp-3;
border-radius: $r-card;
position: relative;
overflow: hidden;
display: grid;
place-items: end start;
box-sizing: border-box;
}
.pm__slide:last-child { margin-right: 0; }
.pm__slide::before {
content: ""; position: absolute; top: $sp-4; right: $sp-4;
width: $sp-8; height: $sp-8; border-radius: 50%;
background: rgba(255, 255, 255, .85);
}
.pm__slide::after {
content: ""; position: absolute; left: -8%; right: -8%; bottom: -6%; height: 48%;
background: rgba(23, 20, 26, .24);
clip-path: polygon(0% 100%, 20% 40%, 40% 66%, 58% 26%, 76% 60%, 100% 34%, 100% 100%);
}
.pm__slide--1 { background: linear-gradient(135deg, $color, #d6ddeb); }
.pm__slide--2 { background: linear-gradient(135deg, $stage-orange, #ebdbd6); }
.pm__slide--3 { background: linear-gradient(135deg, $subject-ink, #4a3f5c); }
.pm__cap {
position: relative; z-index: 1;
margin: $sp-3;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(23, 20, 26, .38);
color: $subject-cream;
font-weight: 600;
font-size: clamp(11px, 3vw, 13px);
line-height: 1;
font-family: inherit;
}
// ── 데모 루프: 클릭 전까지 진행 바와 트랙이 같은 박자로 스스로 넘어간다 ──
// 트랙의 마지막 칸은 1번의 복제본 — 100%→0% 점프가 안 보인다.
// 진행 바는 한 바퀴가 끝나면 100%→1/3 로 부드럽게 빠져 돌아간다(스토리 프로그레스 링과 같은 리셋)
.pm.is-demo .pm__track { animation: pm-track $dur-loop $easing infinite; }
.pm.is-demo .pm__fill { animation: pm-fill $dur-loop $easing infinite; }
@keyframes pm-track {
0%, 8% { transform: translateX(0%); }
31%, 39% { transform: translateX(calc(-25% - #{$sp-3})); }
62%, 70% { transform: translateX(calc(-50% - #{$sp-3 * 2})); }
92%, 100% { transform: translateX(calc(-75% - #{$sp-3 * 3})); }
}
@keyframes pm-fill {
0%, 8% { width: calc(100% / 3); }
31%, 39% { width: calc(200% / 3); }
62%, 66% { width: 100%; }
88%, 100% { width: calc(100% / 3); }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.pm__track, .pm__fill { transition: none; }
.pm.is-demo .pm__track, .pm.is-demo .pm__fill { 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);
}
.pm {
width: min(360px, 84vw);
}
.pm__radio {
position: absolute;
opacity: 0;
pointer-events: none;
}
.pm__bar {
position: relative;
display: flex;
height: 4px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.16);
overflow: hidden;
margin-bottom: 12px;
}
.pm__fill {
position: absolute;
left: 0;
top: 0;
display: block;
height: 100%;
width: 33.3333333333%;
border-radius: 999px;
background: #2f6df6;
transition: width 300ms ease-out;
}
.pm__seg {
position: relative;
z-index: 1;
flex: 1;
cursor: pointer;
}
#pm-2:checked ~ .pm__bar .pm__fill {
width: 66.6666666667%;
}
#pm-3:checked ~ .pm__bar .pm__fill {
width: 100%;
}
.pm__viewport {
overflow: hidden;
border-radius: 16px;
}
.pm__track {
display: flex;
width: 400%;
transform: translateX(0%);
transition: transform 300ms ease-out;
}
#pm-2:checked ~ .pm__viewport .pm__track {
transform: translateX(calc(-25% - 12px));
}
#pm-3:checked ~ .pm__viewport .pm__track {
transform: translateX(calc(-50% - 24px));
}
.pm__slide {
flex: 0 0 25%;
aspect-ratio: 16/9;
margin-right: 12px;
border-radius: 16px;
position: relative;
overflow: hidden;
display: grid;
place-items: end start;
box-sizing: border-box;
}
.pm__slide:last-child {
margin-right: 0;
}
.pm__slide::before {
content: "";
position: absolute;
top: 16px;
right: 16px;
width: 32px;
height: 32px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.85);
}
.pm__slide::after {
content: "";
position: absolute;
left: -8%;
right: -8%;
bottom: -6%;
height: 48%;
background: rgba(23, 20, 26, 0.24);
clip-path: polygon(0% 100%, 20% 40%, 40% 66%, 58% 26%, 76% 60%, 100% 34%, 100% 100%);
}
.pm__slide--1 {
background: linear-gradient(135deg, #2f6df6, #d6ddeb);
}
.pm__slide--2 {
background: linear-gradient(135deg, #ff4d1f, #ebdbd6);
}
.pm__slide--3 {
background: linear-gradient(135deg, #17141a, #4a3f5c);
}
.pm__cap {
position: relative;
z-index: 1;
margin: 12px;
padding: 4px 12px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.38);
color: #fff7e6;
font-weight: 600;
font-size: clamp(11px, 3vw, 13px);
line-height: 1;
font-family: inherit;
}
.pm.is-demo .pm__track {
animation: pm-track 2s ease-out infinite;
}
.pm.is-demo .pm__fill {
animation: pm-fill 2s ease-out infinite;
}
@keyframes pm-track {
0%, 8% {
transform: translateX(0%);
}
31%, 39% {
transform: translateX(calc(-25% - 12px));
}
62%, 70% {
transform: translateX(calc(-50% - 24px));
}
92%, 100% {
transform: translateX(calc(-75% - 36px));
}
}
@keyframes pm-fill {
0%, 8% {
width: 33.3333333333%;
}
31%, 39% {
width: 66.6666666667%;
}
62%, 66% {
width: 100%;
}
88%, 100% {
width: 33.3333333333%;
}
}
@media (prefers-reduced-motion: reduce) {
.pm__track, .pm__fill {
transition: none;
}
.pm.is-demo .pm__track, .pm.is-demo .pm__fill {
animation: none !important;
}
}// 실제 동작은 :checked 만으로 끝난다(0줄). 이 3줄은 미리보기 자동 재생만 멈춘다.
document.querySelectorAll('.pm__radio').forEach(function (r) {
r.addEventListener('pointerdown', function () { document.querySelector('.pm').classList.remove('is-demo'); });
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ProgressMarker.module.scss";
const SLIDES = ["01 · 브랜드 소개", "02 · 이번 시즌 컬렉션", "03 · 매장 안내"];
export type ProgressMarkerProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string; "--i"?: number };
export default function ProgressMarker({ duration = 300, easing = "ease-out", color = "#2f6df6" }: ProgressMarkerProps) {
const [active, setActive] = useState(0);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color, "--i": active };
return (
<div className={styles.pm} style={vars}>
<div className={styles.bar}>
<span className={styles.fill} style={{ width: `${((active + 1) / SLIDES.length) * 100}%` }} />
{SLIDES.map((label, i) => (
<button key={label} type="button" aria-label={`${i + 1}번으로 이동`} className={styles.seg} onClick={() => setActive(i)} />
))}
</div>
<div className={styles.viewport}>
<div className={styles.track} style={{ transform: `translateX(calc(${-active * 100}% / ${SLIDES.length}))` }}>
{SLIDES.map((label, i) => (
<button key={label} type="button" className={styles.slide} onClick={() => setActive(i)}>
<span className={styles.cap}>{label}</span>
</button>
))}
</div>
</div>
</div>
);
}08 · Progress marker sync — Measured area 51.043% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password
One click, two things moving
The same :checked radio state that moves the slide track also drives the bar's width through the sibling combinator, both landing on one click.
.pm__fill {
width: calc(100% / 3);
transition: width .3s;
}
.pm__seg {
flex: 1;
cursor: pointer;
}
#pm-2:checked ~ .pm__bar .pm__fill { width: calc(200% / 3); }
#pm-3:checked ~ .pm__bar .pm__fill { width: 100%; }
093D coverflow
Tilting back on either side
The center photo stands large and flat while its neighbors tilt back and shrink to either side, like flipping through album covers by hand, and clicking any side photo swings it to the front.

<main class="stage">
<div class="cf is-demo">
<input type="radio" name="cf-tab" id="cf-1" class="cf__radio" checked>
<input type="radio" name="cf-tab" id="cf-2" class="cf__radio">
<input type="radio" name="cf-tab" id="cf-3" class="cf__radio">
<div class="cf__stage">
<label class="cf__slide cf__slide--1" for="cf-1" aria-label="Slide 1"></label>
<label class="cf__slide cf__slide--2" for="cf-2" aria-label="Slide 2"></label>
<label class="cf__slide cf__slide--3" for="cf-3" aria-label="Slide 3"></label>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-enter;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
.cf { width: min(400px, 92vw); }
.cf__radio { position: absolute; opacity: 0; pointer-events: none; }
.cf__stage {
position: relative;
height: clamp(140px, 44vw, 200px);
perspective: 720px;
}
.cf__slide {
position: absolute;
top: 0; left: 50%;
width: 46%;
height: 100%;
margin-left: -23%;
border-radius: $r-card;
cursor: pointer;
transform-style: preserve-3d;
transition: transform $duration $easing, opacity $duration $easing;
}
.cf__slide--1 { background: linear-gradient(160deg, $color, #d6ddeb); }
.cf__slide--2 { background: linear-gradient(160deg, $stage-orange, #ebdbd6); }
.cf__slide--3 { background: linear-gradient(160deg, #c2ae82, $stage-yellow); }
.cf__slide::before {
content: ""; position: absolute; top: $sp-3; right: $sp-3;
width: $sp-6; height: $sp-6; border-radius: 50%;
background: rgba(255, 255, 255, .85);
}
.cf__slide::after {
content: ""; position: absolute; left: -10%; right: -10%; bottom: -6%; height: 46%;
background: rgba(23, 20, 26, .26);
clip-path: polygon(0% 100%, 22% 40%, 42% 66%, 60% 26%, 78% 60%, 100% 34%, 100% 100%);
}
// ── 실제 동작: 고른 라디오의 슬라이드가 정면 중앙으로, 나머지 둘은 좌우로 기울어 물러난다 ──
$center: translateX(-50%) translateZ(0) rotateY(0deg) scale(1);
$prev: translateX(-108%) translateZ(-120px) rotateY(38deg) scale(.74);
$next: translateX(8%) translateZ(-120px) rotateY(-38deg) scale(.74);
#cf-1:checked ~ .cf__stage .cf__slide--1 { transform: $center; z-index: 2; }
#cf-1:checked ~ .cf__stage .cf__slide--2 { transform: $next; z-index: 1; opacity: .7; }
#cf-1:checked ~ .cf__stage .cf__slide--3 { transform: $prev; z-index: 1; opacity: .7; }
#cf-2:checked ~ .cf__stage .cf__slide--2 { transform: $center; z-index: 2; }
#cf-2:checked ~ .cf__stage .cf__slide--3 { transform: $next; z-index: 1; opacity: .7; }
#cf-2:checked ~ .cf__stage .cf__slide--1 { transform: $prev; z-index: 1; opacity: .7; }
#cf-3:checked ~ .cf__stage .cf__slide--3 { transform: $center; z-index: 2; }
#cf-3:checked ~ .cf__stage .cf__slide--1 { transform: $next; z-index: 1; opacity: .7; }
#cf-3:checked ~ .cf__stage .cf__slide--2 { transform: $prev; z-index: 1; opacity: .7; }
// ── 데모 루프: 클릭 전까지 세 장이 스스로 중앙 자리를 돌아가며 맡는다 ──
.cf.is-demo .cf__slide--1 { animation: cf-1 $dur-loop $easing infinite; }
.cf.is-demo .cf__slide--2 { animation: cf-2 $dur-loop $easing infinite; }
.cf.is-demo .cf__slide--3 { animation: cf-3 $dur-loop $easing infinite; }
@keyframes cf-1 {
0%, 4% { transform: $center; opacity: 1; }
29%, 62% { transform: $next; opacity: .7; }
66%, 96% { transform: $prev; opacity: .7; }
100% { transform: $center; opacity: 1; }
}
@keyframes cf-2 {
0%, 29% { transform: $prev; opacity: .7; }
33%, 62% { transform: $center; opacity: 1; }
66%, 96% { transform: $next; opacity: .7; }
100% { transform: $prev; opacity: .7; }
}
@keyframes cf-3 {
0%, 29% { transform: $next; opacity: .7; }
33%, 62% { transform: $prev; opacity: .7; }
66%, 96% { transform: $center; opacity: 1; }
100% { transform: $next; opacity: .7; }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.cf__slide { transition: none; }
.cf.is-demo .cf__slide--1,
.cf.is-demo .cf__slide--2,
.cf.is-demo .cf__slide--3 { 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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cf {
width: min(400px, 92vw);
}
.cf__radio {
position: absolute;
opacity: 0;
pointer-events: none;
}
.cf__stage {
position: relative;
height: clamp(140px, 44vw, 200px);
perspective: 720px;
}
.cf__slide {
position: absolute;
top: 0;
left: 50%;
width: 46%;
height: 100%;
margin-left: -23%;
border-radius: 16px;
cursor: pointer;
transform-style: preserve-3d;
transition: transform 600ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.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::before {
content: "";
position: absolute;
top: 12px;
right: 12px;
width: 24px;
height: 24px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.85);
}
.cf__slide::after {
content: "";
position: absolute;
left: -10%;
right: -10%;
bottom: -6%;
height: 46%;
background: rgba(23, 20, 26, 0.26);
clip-path: polygon(0% 100%, 22% 40%, 42% 66%, 60% 26%, 78% 60%, 100% 34%, 100% 100%);
}
#cf-1:checked ~ .cf__stage .cf__slide--1 {
transform: translateX(-50%) translateZ(0) rotateY(0deg) scale(1);
z-index: 2;
}
#cf-1:checked ~ .cf__stage .cf__slide--2 {
transform: translateX(8%) translateZ(-120px) rotateY(-38deg) scale(0.74);
z-index: 1;
opacity: 0.7;
}
#cf-1:checked ~ .cf__stage .cf__slide--3 {
transform: translateX(-108%) translateZ(-120px) rotateY(38deg) scale(0.74);
z-index: 1;
opacity: 0.7;
}
#cf-2:checked ~ .cf__stage .cf__slide--2 {
transform: translateX(-50%) translateZ(0) rotateY(0deg) scale(1);
z-index: 2;
}
#cf-2:checked ~ .cf__stage .cf__slide--3 {
transform: translateX(8%) translateZ(-120px) rotateY(-38deg) scale(0.74);
z-index: 1;
opacity: 0.7;
}
#cf-2:checked ~ .cf__stage .cf__slide--1 {
transform: translateX(-108%) translateZ(-120px) rotateY(38deg) scale(0.74);
z-index: 1;
opacity: 0.7;
}
#cf-3:checked ~ .cf__stage .cf__slide--3 {
transform: translateX(-50%) translateZ(0) rotateY(0deg) scale(1);
z-index: 2;
}
#cf-3:checked ~ .cf__stage .cf__slide--1 {
transform: translateX(8%) translateZ(-120px) rotateY(-38deg) scale(0.74);
z-index: 1;
opacity: 0.7;
}
#cf-3:checked ~ .cf__stage .cf__slide--2 {
transform: translateX(-108%) translateZ(-120px) rotateY(38deg) scale(0.74);
z-index: 1;
opacity: 0.7;
}
.cf.is-demo .cf__slide--1 {
animation: cf-1 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.cf.is-demo .cf__slide--2 {
animation: cf-2 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.cf.is-demo .cf__slide--3 {
animation: cf-3 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes cf-1 {
0%, 4% {
transform: translateX(-50%) translateZ(0) rotateY(0deg) scale(1);
opacity: 1;
}
29%, 62% {
transform: translateX(8%) translateZ(-120px) rotateY(-38deg) scale(0.74);
opacity: 0.7;
}
66%, 96% {
transform: translateX(-108%) translateZ(-120px) rotateY(38deg) scale(0.74);
opacity: 0.7;
}
100% {
transform: translateX(-50%) translateZ(0) rotateY(0deg) scale(1);
opacity: 1;
}
}
@keyframes cf-2 {
0%, 29% {
transform: translateX(-108%) translateZ(-120px) rotateY(38deg) scale(0.74);
opacity: 0.7;
}
33%, 62% {
transform: translateX(-50%) translateZ(0) rotateY(0deg) scale(1);
opacity: 1;
}
66%, 96% {
transform: translateX(8%) translateZ(-120px) rotateY(-38deg) scale(0.74);
opacity: 0.7;
}
100% {
transform: translateX(-108%) translateZ(-120px) rotateY(38deg) scale(0.74);
opacity: 0.7;
}
}
@keyframes cf-3 {
0%, 29% {
transform: translateX(8%) translateZ(-120px) rotateY(-38deg) scale(0.74);
opacity: 0.7;
}
33%, 62% {
transform: translateX(-108%) translateZ(-120px) rotateY(38deg) scale(0.74);
opacity: 0.7;
}
66%, 96% {
transform: translateX(-50%) translateZ(0) rotateY(0deg) scale(1);
opacity: 1;
}
100% {
transform: translateX(8%) translateZ(-120px) rotateY(-38deg) scale(0.74);
opacity: 0.7;
}
}
@media (prefers-reduced-motion: reduce) {
.cf__slide {
transition: none;
}
.cf.is-demo .cf__slide--1,
.cf.is-demo .cf__slide--2,
.cf.is-demo .cf__slide--3 {
animation: none !important;
}
}// 실제 동작은 :checked 만으로 끝난다(0줄). 이 3줄은 미리보기 자동 재생만 멈춘다.
document.querySelectorAll('.cf__radio').forEach(function (r) {
r.addEventListener('pointerdown', function () { document.querySelector('.cf').classList.remove('is-demo'); });
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./Coverflow3d.module.scss";
export type Coverflow3dProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
function roleOf(i: number, active: number, n: number): "center" | "prev" | "next" {
if (i === active) return "center";
return (i - active + n) % n === 1 ? "next" : "prev";
}
export default function Coverflow3d({ duration = 600, easing = "cubic-bezier(.2,.8,.2,1)", color = "#2f6df6" }: Coverflow3dProps) {
const [active, setActive] = useState(0);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const n = 3;
return (
<div className={styles.cf} style={vars}>
<div className={styles.stageBox}>
{[0, 1, 2].map((i) => (
<button
key={i}
type="button"
aria-label={`슬라이드 ${i + 1}`}
className={`${styles.slide} ${styles["slide" + (i + 1)]} ${styles[roleOf(i, active, n)]}`}
onClick={() => setActive(i)}
/>
))}
</div>
</div>
);
}09 · 3D coverflow — Measured area 36.764% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password
What creates the depth
A perspective on the stage plus rotateY and translateZ on each slide create the depth, with only the transform values swapping between whichever slide is :checked.
.cf__stage { perspective: 720px; }
.cf__slide { transform-style: preserve-3d; }
#cf-2:checked ~ .cf__stage .cf__slide--2 {
transform: translateX(-50%) translateZ(0) rotateY(0deg) scale(1);
}
#cf-2:checked ~ .cf__stage .cf__slide--3 {
transform: translateX(8%) translateZ(-120px) rotateY(-38deg) scale(.74);
}
Where it breaks — the trap
Transforming the scroll box itself
Images cropping because the track math drifted is solved by the same aspect-ratio rule used in the CSS image reveal gallery.
The biggest trap while building this set came from item 01, before the viewport and track were split apart. The instinct is to put the autoplay transform straight on .cs__viewport — the element carrying overflow-x: auto — since that's the box that visibly scrolls. Do that and the whole strip vanishes: measuring it live with getBoundingClientRect showed the track sliding to -822px, more than double the viewport's own 400px width, because translateX(-100%) always resolves against the element's own width, never the width of everything scrolling inside it. The fix that stuck was splitting scroll box from content — a viewport that only ever scrolls, and a track inside it that only the autoplay loop's transform ever touches — and every scroll-driven item above, 01 through 04 plus 08, keeps that same two-layer split. A smaller wrinkle turned up in item 03: Chromium's :disabled state on ::scroll-button() isn't pixel-perfect right at the last frame, so an arrow can look faintly visible at the very end even though clicking it does nothing, and the scroll boundary itself is what actually stops the click regardless of how the button looks. The zip below opens with 9jk4bv6c, sitting inside this very sentence exactly as typed, no extra spaces added or trimmed anywhere.
Accessibility
What stops under reduced motion
Every item above turns off its own loop under prefers-reduced-motion: reduce — the demo-only animation switches to animation: none !important, so a real drag, click, or keypress is the only thing that still moves a slide. 01 through 04 and 08 are native scroll containers or radio groups, so Tab and the arrow keys already work with no extra markup, and the two ::scroll-marker items (02 and 04) additionally drop their marker's transition so switching slides jumps straight to the end state instead of easing there. 07 and 09 each carry aria-label="Slide N" on their radio labels so a screen reader can tell the cards apart, and 08's three click targets on the progress bar carry a matching "Go to slide N" label. 05 and 06 run on autoplay with no button at all — hovering the mouse is the only pause control item 05 offers, so prefers-reduced-motion is the more reliable stop for anyone who can't hover, a touch-only visitor included. MDN documents exactly what triggers prefers-reduced-motion on each platform.
How well supported is ::scroll-marker today?
Only Chromium ships it so far
02, 03, and 04 lean on ::scroll-marker, ::scroll-marker-group, ::scroll-button(), and :target-current, all part of the still-moving CSS Overflow Module Level 5 draft — new enough that support for this post was checked directly with CSS.supports('selector(::scroll-marker)') in a real Chrome build rather than assumed from a spec page. As of this writing, only Chromium browsers ship it — Chrome and Edge, with Opera right behind on the same engine — while Safari and Firefox both still list it as unsupported, so the feature isn't Baseline yet. None of that breaks the carousel itself: overflow-x: auto and scroll-snap-type, what 01, 05, 06, 07, 08, and 09 actually run on, have worked everywhere for years, so a browser without the newer pseudo-elements just shows the same swipeable strip minus the dots or arrows drawn on top of it.
Where the rest lives
More scroll-driven CSS lives in the CSS category hub, and what this site does and doesn't sell is on the about page.
FAQ
Will the dots and arrows from ::scroll-marker show up in every browser?
Not yet everywhere — only Chromium browsers support it so far, and Safari and Firefox haven't shipped it yet. Nothing about the base carousel depends on it, though: drop ::scroll-marker support and item 01's plain scroll-snap-type strip keeps working, just without the dots or arrows drawn on top.
What happens if someone drags a css only carousel autoplay demo while it runs?
The moment a slide gets touched, a three-line pointerdown listener strips the class driving the demo loop, so the animation stops right there and whatever the user does next — drag, click, or keyboard — is the only thing moving the strip from then on.
Can this be rebuilt in React instead of raw HTML and radio inputs?
Yes — swap the :checked radio group for one useState index, then pass that index down as a CSS variable so transform: translateX(calc(var(--i) * -100%)) (or the matching scale/rotateY math for 07 and 09) can place the active slide. $duration, $easing, and $color from the SCSS above become props passed the same way. A finished slider with arrows and dots attached is built out in the CSS only carousel slider recipes.









