CSS only carousel — 9 image slider css dán là chạy
CSS only carousel là màn hình cho vài tấm ảnh trượt ngang lần lượt, kiểu hay gặp ở shop online hay trang portfolio.
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
Tự chạy · bấm ô để tới mục đó · cả 9 trong một zip
- 01 scroll-snap cơ bản
- 02 Chấm ::scroll-marker
- 03 Mũi tên ::scroll-button
- 04 Marker hình thu nhỏ
- 05 Tự động phát chỉ bằng CSS
- 06 Slider đánh giá
- 07 Phóng to slide đang xem
- 08 Đồng bộ marker tiến trình
- 09 Coverflow 3D
Chín bản css carousel dưới đây không xếp theo độ nổi tiếng mà theo vai trò mà carousel đảm nhận trên màn hình. Từ bản cơ bản nhất đẩy bằng tay (01), chuyển sang nhóm bày cách điều khiển bằng chấm, mũi tên, thumbnail (02~04), rồi nhóm tự quay dù bạn không đụng tới (05~06), và cuối cùng là nhóm trang trí nhấn mạnh bạn đang xem tấm nào (07~09).
01scroll-snap cơ bản
Thẻ dừng khít vào khung
Đây là dạng image slider css cơ bản nhất: bốn thẻ ảnh vuông nằm ngang cạnh nhau, đẩy bằng ngón tay hay con lăn chuột thì thẻ dừng khít đúng khung màn hình.

<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">Hàng mới về</span></div>
<div class="cs__slide cs__slide--2"><span class="cs__cap">Top bán chạy</span></div>
<div class="cs__slide cs__slide--3"><span class="cs__cap">Sự kiện freeship</span></div>
<div class="cs__slide cs__slide--4"><span class="cs__cap">Đánh giá khách hàng</span></div>
<div class="cs__slide cs__slide--1" aria-hidden="true"><span class="cs__cap">Hàng mới về</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 cơ bản — Diện tích đo được 62.372% · khung hình chuyển động 19/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Vì sao chỉ hai thuộc tính là xong
Gắn overflow-x:auto với scroll-snap-type là hết việc, không còn dòng JavaScript nào.
.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;
}
02Chấm ::scroll-marker
Chỉ chấm đang xem được tô đầy
Dưới slide có mấy chấm nhỏ, chỉ chấm của tấm ảnh bạn đang xem mới được tô màu và to lên một chút.

<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">Lookbook hè</span></div>
<div class="dm__slide dm__slide--2"><span class="dm__cap">Yêu thích</span></div>
<div class="dm__slide dm__slide--3"><span class="dm__cap">Giảm giá sốc</span></div>
<div class="dm__slide dm__slide--1 dm__slide--clone" aria-hidden="true"><span class="dm__cap">Lookbook hè</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 · Chấm ::scroll-marker — Diện tích đo được 49.997% · khung hình chuyển động 20/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Trình duyệt tự vẽ chấm
Chấm không phải do JavaScript vẽ, trình duyệt tự tạo cho mỗi slide một cái bằng ::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);
}
03Mũi tên ::scroll-button
Mũi tên đưa qua từng tấm
Hai bên slide có nút mũi tên trước, sau, bấm vào thì ảnh trượt mượt qua từng tấm một.

<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">Sản phẩm mới</span></div>
<div class="ab__slide ab__slide--2"><span class="ab__cap">Số lượng có hạn</span></div>
<div class="ab__slide ab__slide--3"><span class="ab__cap">Đồ thiết yếu mùa đông</span></div>
<div class="ab__slide ab__slide--1" aria-hidden="true"><span class="ab__cap">Sản phẩm mới</span></div>
<div class="ab__slide ab__slide--1" aria-hidden="true"><span class="ab__cap">Sản phẩm mới</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 · Mũi tên ::scroll-button — Diện tích đo được 56.113% · khung hình chuyển động 20/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Kiểu mờ đi ở hai đầu
Nút mũi tên cũng do trình duyệt tự tạo bằng ::scroll-button(), và được tạo kiểu để mờ đi khi chạm tới hai đầu.
.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 làm marker
Thumbnail khớp với ảnh lớn
Dưới ảnh lớn là dãy thumbnail vuông nhỏ, chỉ thumbnail cùng màu với ảnh lớn đang xem mới hiện viền.
<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">Xanh dương</span></div>
<div class="tm__slide tm__slide--2" style="--thumb:#c89a8f"><span class="tm__cap">Cam</span></div>
<div class="tm__slide tm__slide--3" style="--thumb:#17141a"><span class="tm__cap">Đen</span></div>
<div class="tm__slide tm__slide--1 tm__slide--clone" style="--thumb:#93a4ca" aria-hidden="true"><span class="tm__cap">Xanh dương</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 · Marker hình thu nhỏ — Diện tích đo được 49.928% · khung hình chuyển động 19/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Cách kế thừa màu
Thumbnail cũng chỉ là một ::scroll-marker, còn màu thì kế thừa thẳng biến CSS ghi sẵn trên slide gốc.
.slide { background: var(--thumb); }
.slide::scroll-marker {
content: "";
background: var(--thumb);
border: 2px solid transparent;
}
.slide::scroll-marker:target-current {
border-color: $color;
}
05Autoplay chỉ bằng CSS
Cho carousel tự quay
Bạn không bấm gì cả mà cứ vài giây một lần ảnh lại tự chuyển sang tấm kế tiếp.

<main class="stage">
<div class="ap">
<div class="ap__track">
<div class="ap__slide ap__slide--1"><span class="ap__cap">Đang diễn ra</span></div>
<div class="ap__slide ap__slide--2"><span class="ap__cap">Ưu đãi thành viên mới</span></div>
<div class="ap__slide ap__slide--3"><span class="ap__cap">Giảm giá mùa đông</span></div>
<div class="ap__slide ap__slide--4"><span class="ap__cap">Coupon thành viên</span></div>
<div class="ap__slide ap__slide--1" aria-hidden="true"><span class="ap__cap">Đang diễn ra</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 · Tự động phát chỉ bằng CSS — Diện tích đo được 62.373% · khung hình chuyển động 20/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Rê chuột vào là dừng
Rê chuột lên thì nó dừng một lát, đủ để bạn xem kỹ tấm mình muốn.
.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%); }
}
06Slider đánh giá
Đổi từng tấm bằng fade
Card đánh giá của khách hàng đổi từng tấm bằng fade rồi tự xoay vòng liên tục.

<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">"Hàng giao ngay hôm sau. Đóng gói cũng cẩn thận."</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">"Màu giống hệt ảnh nên rất bất ngờ. Chắc chắn mua lại."</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">"CSKH phản hồi nhanh lại thân thiện, rất đáng tin."</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 · Slider đánh giá — Diện tích đo được 3.304% · khung hình chuyển động 12/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Vì sao chiều cao không rung
Các card xếp chồng đúng một chỗ nên dù chữ đánh giá dài ngắn khác nhau, chiều cao vẫn không rung lên xuống.
.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; } }
07Phóng to slide đang xem
Giữa to lên, hai bên mờ đi
Tấm ở giữa được phóng to hơn chút, hai tấm bên cạnh nhỏ lại và mờ đi, nên bạn nắm ngay mình đang xem tấm nào.

<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 · Phóng to slide đang xem — Diện tích đo được 46.955% · khung hình chuyển động 9/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Bấm vào là ra giữa
Bấm vào tấm bạn muốn thì tấm đó ra giữa và to trở lại.
.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;
}
08Marker tiến độ liên động
Thanh đầy theo số tấm đã qua
Phía trên slide có một thanh tiến độ mỏng, ảnh chuyển tới đâu thanh đầy tới đó.

<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="Chuyển tới slide 1"></label>
<label class="pm__seg" for="pm-2" aria-label="Chuyển tới slide 2"></label>
<label class="pm__seg" for="pm-3" aria-label="Chuyển tới slide 3"></label>
</div>
<div class="pm__viewport">
<div class="pm__track">
<div class="pm__slide pm__slide--1"><span class="pm__cap">01 · Câu chuyện thương hiệu</span></div>
<div class="pm__slide pm__slide--2"><span class="pm__cap">02 · Bộ sưu tập mùa này</span></div>
<div class="pm__slide pm__slide--3"><span class="pm__cap">03 · Thông tin cửa hàng</span></div>
<div class="pm__slide pm__slide--1" aria-hidden="true"><span class="pm__cap">01 · Câu chuyện thương hiệu</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 · Đồng bộ marker tiến trình — Diện tích đo được 51.043% · khung hình chuyển động 21/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Bấm thanh để nhảy thẳng
Bấm thẳng vào thanh tiến độ cũng nhảy ngay tới tấm ảnh ở chỗ bạn bấm.
.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%; }
09Coverflow 3D
Như lật bìa đĩa nhạc
Tấm giữa đứng thẳng mặt và to, các tấm hai bên nghiêng chéo lùi ra sau và nhỏ lại, trông như bạn đang lật bìa đĩa nhạc bằng tay.

<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 · Coverflow 3D — Diện tích đo được 36.764% · khung hình chuyển động 10/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Bấm tấm nào, tấm đó quay ra trước
Bấm vào tấm bạn muốn thì tấm đó quay ra chính giữa, đối diện người xem.
.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);
}
Carousel dễ hỏng ở đâu — một cái bẫy
Khi bạn gắn transform lên hộp cuộn
Cái bẫy lớn nhất trong chín mục rơi vào lúc dựng bản xem trước của autoplay. Gắn translateX thẳng lên hộp cuộn (phần tử mang overflow-x:auto) thì toàn bộ thẻ biến mất khỏi màn hình. Con số 100% trong translateX(-100%) luôn tính theo bề rộng của chính phần tử đó, nên nó chỉ đẩy đi đúng bề rộng hộp cuộn (bằng một thẻ) chứ không phải bề rộng nội dung bốn thẻ nằm trong hộp. Đo trực tiếp bằng getBoundingClientRect thì lượng dịch mong muốn là bề rộng bốn thẻ cộng lại (quanh 1600px), còn thực tế chỉ nhích đúng bề rộng một thẻ (400px), và chênh lệch ấy đẩy thẻ ra hẳn ngoài viewport, màn hình chỉ còn trơ nền sân khấu. Vậy nên phải để nguyên hộp cuộn và chỉ gắn transform lên hàng nội dung (track) bên trong thì kéo tay và snap vẫn sống nguyên mà autoplay cũng chạy. Mật khẩu 9jk4bv6c của file nén này mở chín file đã sửa đúng theo cách ấy.
:disabled của scroll-button chưa thật gọn
Trạng thái :disabled của ::scroll-button() — tức mũi tên mờ đi ở hai đầu — thử ra thì ngay cả trên nhánh Chromium cũng chưa hoàn toàn gọn ghẽ, nên dù mũi tên còn hiện nhạt, chín bản này vẫn chặn để nó không bấm thêm được nữa ở biên cuộn.
Khả năng tiếp cận
Cái gì dừng lại khi giảm chuyển động
Cả chín mục đều tắt animation autoplay dưới prefers-reduced-motion: reduce và đứng yên ở slide bạn đang xem. Mục 07·08·09 dựng bằng radio là form control gốc nên Tab và phím mũi tên điều khiển được ngay, còn 02·03·04 dùng ::scroll-marker với ::scroll-button thì chỉ cần focus tới là phím mũi tên chuyển slide. Mỗi thẻ của 07·09 mang aria-label="slide N" để trình đọc màn hình phân biệt được các thẻ với nhau.
Bài đọc tiếp
Các hiệu ứng CSS khác dùng cho portfolio hay landing page thì tìm thêm ở mục CSS animation, còn cách trang này vận hành thì ghi ở trang giới thiệu. Hành vi chính xác của scroll-snap-type xem tại tài liệu MDN về scroll-snap-type.
FAQ
::scroll-marker của css only carousel chạy trên mọi trình duyệt chứ?
Tại thời điểm viết bài, hành vi này đã được xác nhận trực tiếp trên nhánh Chromium. Trình duyệt chưa hỗ trợ thì chỉ mất chấm với mũi tên thôi, còn scroll-snap-type vẫn hoạt động bình thường, nên nó lùi êm về dạng cơ bản (01) đẩy bằng ngón tay hay con lăn.
Người dùng kéo tay giữa lúc autoplay thì sao?
Ngay khoảnh khắc chạm vào slide (pointerdown), class autoplay bị gỡ liền, từ đó về sau chỉ còn thao tác cuộn và bấm của người dùng có tác dụng. Không có chuyện autoplay giành nhau với thao tác thật.
Bản React có nguyên lý khác không?
Mục 01~04 chép nguyên scroll-snap-type, ::scroll-marker, ::scroll-button() sang CSS module nên nguyên lý y hệt. Chỉ mấy mục vốn chọn bằng radio input như 07~09 mới đổi sang một useState, còn duration, easing, color thì nhận qua props — ai cần css carousel scroll snap cho dự án React thì dùng thẳng phần này.









