이미지 슬라이드 만들기 9종 — CSS 캐러셀 그대로 복사
이미지 슬라이드 만들기는 쇼핑몰이나 포트폴리오 사이트에서 사진 몇 장이 옆으로 넘어가며 순서대로 보이는 화면을 만드는 일입니다. AOS·GSAP 없이, JS 0줄로 CSS만 붙여 넣어도 아홉 가지 방식으로 만들 수 있습니다.
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
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 기본 scroll-snap
- 02 ::scroll-marker 도트
- 03 ::scroll-button 화살표
- 04 썸네일 마커
- 05 CSS만으로 자동재생
- 06 후기 슬라이더
- 07 활성 슬라이드 확대
- 08 진행 마커 연동
- 09 3D 커버플로우
아홉 개 css 슬라이더는 인기 순이 아니라 캐러셀이 화면에서 맡는 역할 순서로 놓았습니다. 손으로 미는 가장 기본형(01)에서 점·화살표·썸네일로 조작 방법을 보여주는 것(02~04)으로 넘어가고, 그다음은 손대지 않아도 저절로 도는 것(05~06), 마지막은 지금 뭘 보고 있는지 강조하는 장식형(07~09) 순서입니다. 몇 장째를 보고 있는지 막대로 알리고 싶다면 스크롤 진행 표시가 그 눈금을 만들어 주고, 첫 화면을 캐러셀로 시작할 생각이면 히어로 섹션 애니메이션이 앞뒤를 맞춰 줍니다.
01기본 scroll-snap
카드가 딱 맞춰 멈추는 동작
html css 이미지 슬라이드의 가장 기본형으로, 네모난 사진 카드 네 장이 가로로 나란히 있고, 손가락이나 마우스 휠로 밀면 카드가 화면에 딱 맞춰 멈춰 섭니다.

<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">신상 컬렉션</span></div>
<div class="cs__slide cs__slide--2"><span class="cs__cap">베스트셀러 TOP</span></div>
<div class="cs__slide cs__slide--3"><span class="cs__cap">무료 배송 이벤트</span></div>
<div class="cs__slide cs__slide--4"><span class="cs__cap">고객 후기</span></div>
<div class="cs__slide cs__slide--1" aria-hidden="true"><span class="cs__cap">신상 컬렉션</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 · 기본 scroll-snap — 측정 누적면적 62.372% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
두 속성만으로 끝나는 이유
overflow-x:auto와 scroll-snap-type 두 속성만 붙이면 끝나서 자바스크립트가 한 줄도 없습니다.
.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 도트
지금 보는 점만 채워진다
슬라이드 아래 작은 점들이 있고, 지금 보고 있는 사진의 점만 색이 채워지고 살짝 커집니다.

<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">여름 룩북</span></div>
<div class="dm__slide dm__slide--2"><span class="dm__cap">위시리스트</span></div>
<div class="dm__slide dm__slide--3"><span class="dm__cap">할인 특가</span></div>
<div class="dm__slide dm__slide--1 dm__slide--clone" aria-hidden="true"><span class="dm__cap">여름 룩북</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 도트 — 측정 누적면적 49.997% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
브라우저가 점을 그린다
점은 자바스크립트로 그린 게 아니라 브라우저가 ::scroll-marker로 슬라이드마다 하나씩 자동으로 만들어 줍니다.
.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 화살표
한 칸씩 넘어가는 화살표
슬라이드 양옆에 이전·다음 화살표 버튼이 있고, 누르면 사진이 한 칸씩 부드럽게 넘어갑니다.

<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">신제품</span></div>
<div class="ab__slide ab__slide--2"><span class="ab__cap">한정 수량</span></div>
<div class="ab__slide ab__slide--3"><span class="ab__cap">겨울 준비물</span></div>
<div class="ab__slide ab__slide--1" aria-hidden="true"><span class="ab__cap">신제품</span></div>
<div class="ab__slide ab__slide--1" aria-hidden="true"><span class="ab__cap">신제품</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 화살표 — 측정 누적면적 56.113% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
맨 끝에서 흐려지는 스타일
화살표 버튼도 브라우저가 ::scroll-button()으로 자동으로 만들고, 맨 끝에서는 흐려지도록 스타일을 입힙니다.
.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;
}
04썸네일 마커
큰 사진과 짝 맞는 썸네일
큰 사진 아래 작은 정사각 썸네일들이 줄지어 있고, 지금 보는 큰 사진과 같은 색의 썸네일에만 테두리가 생깁니다.
<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">블루</span></div>
<div class="tm__slide tm__slide--2" style="--thumb:#c89a8f"><span class="tm__cap">오렌지</span></div>
<div class="tm__slide tm__slide--3" style="--thumb:#17141a"><span class="tm__cap">블랙</span></div>
<div class="tm__slide tm__slide--1 tm__slide--clone" style="--thumb:#93a4ca" aria-hidden="true"><span class="tm__cap">블루</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 · 썸네일 마커 — 측정 누적면적 49.928% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
색을 물려받는 방법
썸네일도 ::scroll-marker 하나로 만들고, 색은 원본 슬라이드에 적어 둔 CSS 변수를 그대로 물려받습니다.
.slide { background: var(--thumb); }
.slide::scroll-marker {
content: "";
background: var(--thumb);
border: 2px solid transparent;
}
.slide::scroll-marker:target-current {
border-color: $color;
}
05CSS만으로 자동재생
css 캐러셀을 저절로 돌게 하기
아무것도 누르지 않아도 사진이 몇 초마다 저절로 다음 장으로 넘어갑니다.

<main class="stage">
<div class="ap">
<div class="ap__track">
<div class="ap__slide ap__slide--1"><span class="ap__cap">지금 진행 중</span></div>
<div class="ap__slide ap__slide--2"><span class="ap__cap">신규 가입 혜택</span></div>
<div class="ap__slide ap__slide--3"><span class="ap__cap">겨울 시즌 오프</span></div>
<div class="ap__slide ap__slide--4"><span class="ap__cap">회원 전용 쿠폰</span></div>
<div class="ap__slide ap__slide--1" aria-hidden="true"><span class="ap__cap">지금 진행 중</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만으로 자동재생 — 측정 누적면적 62.373% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
마우스를 올리면 멈춘다
마우스를 올리면 잠깐 멈춰서 원하는 사진을 편하게 볼 수 있습니다.
.ap__track {
display: flex;
width: 400%;
animation: ap-cycle $duration $easing infinite;
}
.ap:hover .ap__track { animation-play-state: paused; }
@keyframes ap-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%); }
}
06후기 슬라이더
한 장씩 페이드로 바뀐다
고객 후기 카드가 한 장씩 페이드로 바뀌며 저절로 순환합니다.

<main class="stage">
<div class="ts">
<div class="ts__stack">
<figure class="ts__card ts__card--1">
<span class="ts__avatar">민</span>
<blockquote class="ts__quote">"배송이 다음 날 바로 왔어요. 포장도 꼼꼼했습니다."</blockquote>
<figcaption class="ts__name">김민지</figcaption>
</figure>
<figure class="ts__card ts__card--2">
<span class="ts__avatar">서</span>
<blockquote class="ts__quote">"색상이 사진이랑 똑같아서 놀랐어요. 재구매 예정."</blockquote>
<figcaption class="ts__name">이서준</figcaption>
</figure>
<figure class="ts__card ts__card--3">
<span class="ts__avatar">하</span>
<blockquote class="ts__quote">"고객센터 응대가 빠르고 친절해서 믿음이 갑니다."</blockquote>
<figcaption class="ts__name">박하은</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 · 후기 슬라이더 — 측정 누적면적 3.304% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
높이가 흔들리지 않는 이유
후기 글자 길이가 서로 달라도 카드들을 같은 자리에 겹쳐 둬서 높이가 흔들리지 않습니다.
.ts__card { grid-area: 1 / 1; opacity: 0; }
.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; }
@keyframes ts-1 { 0%, 4% { opacity: 1; } 29%, 100% { opacity: 0; } }
@keyframes ts-2 { 0%, 29% { opacity: 0; } 33%, 62% { opacity: 1; } 66%, 100% { opacity: 0; } }
@keyframes ts-3 { 0%, 62% { opacity: 0; } 66%, 96% { opacity: 1; } 100% { opacity: 0; } }
07활성 슬라이드 확대
가운데만 커지고 양옆은 흐려진다
가운데 있는 사진만 조금 크게 확대되고 양옆 사진은 작아지고 흐려져서, 지금 보고 있는 게 뭔지 한눈에 들어옵니다.

<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="슬라이드 1"></label>
<label class="as__slide as__slide--2" for="as-2" aria-label="슬라이드 2"></label>
<label class="as__slide as__slide--3" for="as-3" aria-label="슬라이드 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 · 활성 슬라이드 확대 — 측정 누적면적 46.955% · 움직인 프레임 9/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
클릭하면 가운데로 온다
원하는 사진을 클릭하면 그 사진이 가운데로 오며 다시 커집니다.
.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;
}
08진행 마커 연동
넘어간 만큼 채워지는 바
슬라이드 위쪽에 얇은 진행 바가 있고, 사진이 넘어갈 때마다 그만큼 채워집니다.

<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="1번으로 이동"></label>
<label class="pm__seg" for="pm-2" aria-label="2번으로 이동"></label>
<label class="pm__seg" for="pm-3" aria-label="3번으로 이동"></label>
</div>
<div class="pm__viewport">
<div class="pm__track">
<div class="pm__slide pm__slide--1"><span class="pm__cap">01 · 브랜드 소개</span></div>
<div class="pm__slide pm__slide--2"><span class="pm__cap">02 · 이번 시즌 컬렉션</span></div>
<div class="pm__slide pm__slide--3"><span class="pm__cap">03 · 매장 안내</span></div>
<div class="pm__slide pm__slide--1" aria-hidden="true"><span class="pm__cap">01 · 브랜드 소개</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 · 진행 마커 연동 — 측정 누적면적 51.043% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
바를 눌러 바로 이동하기
진행 바를 직접 눌러도 누른 자리의 사진으로 바로 넘어갑니다.
.pm__fill {
width: calc(100% / 3);
transition: width .3s;
}
#pm-2:checked ~ .pm__bar .pm__fill { width: calc(200% / 3); }
#pm-3:checked ~ .pm__bar .pm__fill { width: 100%; }
093D 커버플로우
앨범 커버를 넘기듯
가운데 사진은 정면으로 크게 서 있고 양옆 사진들은 비스듬히 기울어진 채 작게 물러나 있어서, 앨범 커버를 손으로 넘기는 것처럼 보입니다.

<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="슬라이드 1"></label>
<label class="cf__slide cf__slide--2" for="cf-2" aria-label="슬라이드 2"></label>
<label class="cf__slide cf__slide--3" for="cf-3" aria-label="슬라이드 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 커버플로우 — 측정 누적면적 36.764% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
클릭한 사진이 정면으로
원하는 사진을 클릭하면 그 사진이 정면 가운데로 옵니다.
.cf__stage { perspective: 720px; }
.cf__slide { transform-style: preserve-3d; }
#cf-2:checked ~ .cf__stage .cf__slide--2 {
transform: translateX(-50%) rotateY(0deg) scale(1);
}
#cf-2:checked ~ .cf__stage .cf__slide--3 {
transform: translateX(8%) translateZ(-120px) rotateY(-38deg) scale(.74);
}
어디서 깨지는가 — 함정 하나
스크롤 상자에 transform 을 걸면
칸 계산이 어긋나 사진이 잘려 보이는 상황은 CSS 이미지 슬라이드 애니메이션에서도 같은 종횡비 규칙으로 풀립니다.
아홉 개 중 가장 크게 걸린 함정은 자동 재생 미리보기를 만들 때였습니다. 스크롤 상자(overflow-x:auto가 걸린 요소) 자체에 translateX를 걸었더니 카드 전체가 통째로 화면 밖으로 사라졌습니다. translateX(-100%)의 100%는 항상 그 요소 자기 자신의 너비 기준이라, 스크롤 상자 안에 있는 카드 네 장짜리 콘텐츠 폭이 아니라 상자 자신의 폭(카드 한 장 크기)만큼만 밀린 것입니다. getBoundingClientRect로 직접 재 보니 원했던 이동량은 카드 네 장을 합친 폭(1600px 안팎)이었는데 실제로는 딱 카드 한 장 폭(400px)만 움직이고 있었고, 그 차이만큼 카드가 뷰포트 밖으로 완전히 밀려나 화면엔 무대 배경만 남았습니다. 그래서 스크롤 상자는 그대로 두고 그 안의 콘텐츠 줄(트랙)에만 transform을 걸어야 실제 드래그·스냅은 그대로 살아있으면서 자동 재생도 됩니다. 이 압축 파일의 9jk4bv6c은 이렇게 바로잡은 아홉 개 파일을 엽니다.
scroll-button 의 disabled 는 깔끔하지 않다
::scroll-button()의 :disabled 상태(맨 끝에서 화살표가 흐려지는 것)는 테스트해 보니 크로미움 계열에서도 완전히 깔끔하지는 않아서, 화살표가 옅게 남더라도 실제로는 더 눌리지 않도록 스크롤 경계에서 동작이 멈추게 해 뒀습니다.
접근성
동작 줄이기에서 무엇이 멈추나
아홉 개 모두 prefers-reduced-motion: reduce에서 자동 재생 애니메이션이 꺼지고 지금 보고 있는 슬라이드 상태로 고정됩니다. 라디오로 만든 07·08·09는 네이티브 폼 컨트롤이라 Tab과 화살표 키로 바로 움직이고, ::scroll-marker·::scroll-button을 쓴 02·03·04는 포커스가 가면 화살표 키만으로 슬라이드가 넘어갑니다. 07·09의 각 카드에는 aria-label="슬라이드 N"을 달아 스크린 리더가 카드끼리 구분할 수 있게 했습니다.
이어서 볼 글
포트폴리오·랜딩페이지에 쓸 다른 CSS 애니메이션은 CSS 애니메이션 카테고리에서 더 찾을 수 있고, 사이트 운영 방식은 소개 페이지에 적어 뒀습니다. scroll-snap-type의 정확한 동작은 MDN scroll-snap-type 문서에서 확인할 수 있습니다.
FAQ
css only carousel 의 ::scroll-marker 는 모든 브라우저에서 되나요?
이 글을 쓰는 시점 기준으로 크로미움 계열 브라우저에서 동작을 직접 확인했습니다. 아직 지원하지 않는 브라우저에서는 점·화살표만 안 보일 뿐 scroll-snap-type 자체는 정상 동작해서, 손가락이나 휠로 미는 기본형(01)으로 자연스럽게 되돌아갑니다.
자동 재생 중에 사용자가 드래그하면 어떻게 되나요?
슬라이드를 처음 건드리는 순간(pointerdown) 자동 재생 클래스가 바로 빠지고, 그 뒤로는 사용자의 스크롤·클릭만 반영됩니다. 자동 재생과 실제 조작이 동시에 싸우는 일은 없습니다.
React 버전은 원리가 다른가요?
01~04는 CSS 모듈에 같은 scroll-snap-type·::scroll-marker·::scroll-button()을 그대로 옮겨서 원리가 동일합니다. 07~09처럼 원래 라디오 입력으로 고르던 항목만 useState 하나로 바꾸고, duration·easing·color는 props로 받습니다. 화살표와 점까지 붙은 완성형 슬라이더는 CSS 슬라이더 캐러셀 만들기에서 이어 만듭니다.









