06가로 스냅 탭
탭 다섯 개가 상자 폭보다 넓어 가로로 넘치면, scroll-snap-type 이 탭 하나씩 딱딱 끊어 멈추게 하고 고른 탭 아래 밑줄은 여전히 translateX 로 따라갑니다.
쓰임카테고리 필터 탭, 좁은 화면의 다중 탭 메뉴
scroll-snap-typeoverflow-xtranslateX

<main class="stage">
<div class="st is-demo">
<input type="radio" name="st-tab" id="st-1" class="st__radio" checked>
<input type="radio" name="st-tab" id="st-2" class="st__radio">
<input type="radio" name="st-tab" id="st-3" class="st__radio">
<input type="radio" name="st-tab" id="st-4" class="st__radio">
<input type="radio" name="st-tab" id="st-5" class="st__radio">
<div class="st__scroller">
<div class="st__nav">
<label class="st__tab" for="st-1">전체</label>
<label class="st__tab" for="st-2">의류</label>
<label class="st__tab" for="st-3">신발</label>
<label class="st__tab" for="st-4">가방</label>
<label class="st__tab" for="st-5">액세서리</label>
<span class="st__trail" aria-hidden="true"></span>
<span class="st__indicator" aria-hidden="true"></span>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base; // 인디케이터가 미끄러지는 시간
$easing: $ease-lerp-settle; // 관성 lerp — 목표를 12.6% 넘겼다가 정착 (2026-09-15 형태 2차 한 겹)
$color: $subject-ink; // 인디케이터 색 (노란 무대라 어둡게)
@include stage($stage-yellow);
$tab-w: 88px; // 탭 다섯 개가 같은 폭이라 인디케이터 이동값을 고정 배수로 쓸 수 있다
.st { width: min(260px, 78vw); }
.st__radio { position: absolute; opacity: 0; pointer-events: none; }
// 다섯 탭(88px × 5 = 440px)이 상자(260px)보다 넓어 가로로 넘친다 — 그래서 스냅 스크롤이 필요하다
.st__scroller {
overflow-x: auto;
overflow-y: hidden;
scroll-snap-type: x mandatory;
border-radius: $r-sm;
-webkit-overflow-scrolling: touch;
}
.st__nav {
position: relative;
display: flex;
}
// 글자색·굵기는 항상 고정한다 — 밑줄(인디케이터)이 데모 루프에서 :checked 와 다른 탭에 가 있어도
// "이 탭이 진짜 활성" 이라는 잘못된 신호를 안 준다. 활성 신호는 밑줄 위치 하나로만 준다
.st__tab {
flex: 0 0 $tab-w;
scroll-snap-align: start;
text-align: center;
padding: $sp-3 $sp-1;
font-weight: 600;
font-size: clamp(12px, 3.4vw, 13px);
line-height: 1;
font-family: inherit;
color: $subject-ink;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.st__indicator {
position: absolute; left: 0; bottom: 0;
width: $tab-w; height: 3px;
background: $color;
border-radius: $r-pill;
transform: translateX(0);
transition: transform $duration $easing;
}
// 이동 궤적 트레일 잔상 — 인디케이터와 같은 .st__scroller 안(.st__nav 자식, .st__indicator 형제)에 둬야 st-scroll 로 같이 밀린다
.st__trail {
position: absolute; left: 0; bottom: 0;
width: $tab-w; height: 3px;
background: $color;
border-radius: $r-pill;
opacity: .32;
filter: blur(1.5px);
transform: translateX(0);
transition: transform calc($duration * 1.8) ease-out;
}
// ── 실제 동작: :checked 만으로 인디케이터가 옮겨간다 ──────────────
@for $i from 1 through 5 {
#st-#{$i}:checked ~ .st__scroller .st__indicator { transform: translateX(#{$tab-w * ($i - 1)}); }
#st-#{$i}:checked ~ .st__scroller .st__trail { transform: translateX(#{$tab-w * ($i - 1)}); }
}
// ── 데모 루프: 다섯 탭을 다 돈다. 상자(260px)엔 세 탭만 들어가므로
// 뒤 두 탭(가방·액세서리)로 갈 땐 .st__nav 자체를 밀어야 "스크롤"이 보인다 —
// 인디케이터만 옮기면 보이지도 않는 탭으로 움직여 아무 일도 안 일어나는 것처럼 보였다
// (사용자 2026-09-10 스크린샷 지적: 06 이 그냥 밑줄 3개 + 빈 공간으로 보임)
.st.is-demo .st__indicator { animation: st-loop $dur-loop $easing infinite; }
.st.is-demo .st__trail { animation: st-loop $dur-loop $easing infinite; animation-delay: calc($dur-loop * .06); } // 6% 위상만큼 늦게 쫓아가 트레일로 읽힌다
.st.is-demo .st__nav { animation: st-scroll $dur-loop $easing infinite; }
@keyframes st-loop {
0%, 12% { transform: translateX(0); }
24%, 32% { transform: translateX(#{$tab-w}); }
44%, 52% { transform: translateX(#{$tab-w * 2}); }
64%, 72% { transform: translateX(#{$tab-w * 3}); }
84%, 92% { transform: translateX(#{$tab-w * 4}); }
100% { transform: translateX(0); }
}
// 나머지 탭이 상자 안에 보이도록 탭 스트립 자체를 왼쪽으로 민다 (translateX — scrollLeft 는 WAAPI 로 못 잰다)
@keyframes st-scroll {
0%, 12%, 24%, 32% { transform: translateX(0); }
44%, 52% { transform: translateX(#{-$tab-w}); }
64%, 92% { transform: translateX(#{-$tab-w * 2}); }
100% { transform: translateX(0); }
}
@media (prefers-reduced-motion: reduce) {
.st__indicator, .st__trail { transition: none; }
.st.is-demo .st__indicator, .st.is-demo .st__trail, .st.is-demo .st__nav { animation: none !important; }
.st__trail { opacity: 0; } // 관성·트레일 한 겹은 reduced-motion 에서 완전히 꺼진다
}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);
}
.st {
width: min(260px, 78vw);
}
.st__radio {
position: absolute;
opacity: 0;
pointer-events: none;
}
.st__scroller {
overflow-x: auto;
overflow-y: hidden;
scroll-snap-type: x mandatory;
border-radius: 8px;
-webkit-overflow-scrolling: touch;
}
.st__nav {
position: relative;
display: flex;
}
.st__tab {
flex: 0 0 88px;
scroll-snap-align: start;
text-align: center;
padding: 12px 4px;
font-weight: 600;
font-size: clamp(12px, 3.4vw, 13px);
line-height: 1;
font-family: inherit;
color: #17141a;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.st__indicator {
position: absolute;
left: 0;
bottom: 0;
width: 88px;
height: 3px;
background: #17141a;
border-radius: 999px;
transform: translateX(0);
transition: transform 300ms linear(0, 0.051, 0.176, 0.341, 0.518, 0.685, 0.831, 0.947, 1.032, 1.087, 1.117, 1.126, 1.121, 1.105, 1.085, 1.062, 1.041, 1.022, 1.008, 0.997, 1);
}
.st__trail {
position: absolute;
left: 0;
bottom: 0;
width: 88px;
height: 3px;
background: #17141a;
border-radius: 999px;
opacity: 0.32;
filter: blur(1.5px);
transform: translateX(0);
transition: transform 540ms ease-out;
}
#st-1:checked ~ .st__scroller .st__indicator {
transform: translateX(0px);
}
#st-1:checked ~ .st__scroller .st__trail {
transform: translateX(0px);
}
#st-2:checked ~ .st__scroller .st__indicator {
transform: translateX(88px);
}
#st-2:checked ~ .st__scroller .st__trail {
transform: translateX(88px);
}
#st-3:checked ~ .st__scroller .st__indicator {
transform: translateX(176px);
}
#st-3:checked ~ .st__scroller .st__trail {
transform: translateX(176px);
}
#st-4:checked ~ .st__scroller .st__indicator {
transform: translateX(264px);
}
#st-4:checked ~ .st__scroller .st__trail {
transform: translateX(264px);
}
#st-5:checked ~ .st__scroller .st__indicator {
transform: translateX(352px);
}
#st-5:checked ~ .st__scroller .st__trail {
transform: translateX(352px);
}
.st.is-demo .st__indicator {
animation: st-loop 2s linear(0, 0.051, 0.176, 0.341, 0.518, 0.685, 0.831, 0.947, 1.032, 1.087, 1.117, 1.126, 1.121, 1.105, 1.085, 1.062, 1.041, 1.022, 1.008, 0.997, 1) infinite;
}
.st.is-demo .st__trail {
animation: st-loop 2s linear(0, 0.051, 0.176, 0.341, 0.518, 0.685, 0.831, 0.947, 1.032, 1.087, 1.117, 1.126, 1.121, 1.105, 1.085, 1.062, 1.041, 1.022, 1.008, 0.997, 1) infinite;
animation-delay: 0.12s;
}
.st.is-demo .st__nav {
animation: st-scroll 2s linear(0, 0.051, 0.176, 0.341, 0.518, 0.685, 0.831, 0.947, 1.032, 1.087, 1.117, 1.126, 1.121, 1.105, 1.085, 1.062, 1.041, 1.022, 1.008, 0.997, 1) infinite;
}
@keyframes st-loop {
0%, 12% {
transform: translateX(0);
}
24%, 32% {
transform: translateX(88px);
}
44%, 52% {
transform: translateX(176px);
}
64%, 72% {
transform: translateX(264px);
}
84%, 92% {
transform: translateX(352px);
}
100% {
transform: translateX(0);
}
}
@keyframes st-scroll {
0%, 12%, 24%, 32% {
transform: translateX(0);
}
44%, 52% {
transform: translateX(-88px);
}
64%, 92% {
transform: translateX(-176px);
}
100% {
transform: translateX(0);
}
}
@media (prefers-reduced-motion: reduce) {
.st__indicator, .st__trail {
transition: none;
}
.st.is-demo .st__indicator, .st.is-demo .st__trail, .st.is-demo .st__nav {
animation: none !important;
}
.st__trail {
opacity: 0;
}
}// 스크롤은 브라우저 scroll-snap 이 처리한다. 이동은 :checked 만으로 동작. 실제 클릭이면 데모 루프만 뗀다.
document.querySelectorAll('.st__radio').forEach(function (r) {
r.addEventListener('pointerdown', function () { document.querySelector('.st').classList.remove('is-demo'); });
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ScrollTabs.module.scss";
const TABS = ["전체", "의류", "신발", "가방", "액세서리"];
const TAB_W = 88;
export type ScrollTabsProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string; "--i"?: number };
export default function ScrollTabs({ duration = 300, easing = "cubic-bezier(.2,.8,.2,1)", color = "#17141a" }: ScrollTabsProps) {
const [active, setActive] = useState(0);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color, "--i": active };
return (
<div className={styles.st} style={vars}>
<div className={styles.scroller}>
<div className={styles.nav} role="tablist">
{TABS.map((t, i) => (
<button key={t} type="button" role="tab" aria-selected={i === active}
className={styles.tab} style={{ flex: `0 0 ${TAB_W}px` }} onClick={() => setActive(i)}>
{t}
</button>
))}
<span className={styles.indicator} aria-hidden="true" />
</div>
</div>
</div>
);
}06 가로 스냅 탭 — 측정 누적면적 2.21% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · CSS 탭 메뉴 전환 애니메이션 9종 — JS 없이, CSS만으로
이 데모가 속한 글
CSS 탭 메뉴 전환 애니메이션 9종 — JS 없이, CSS만으로CSS 탭 메뉴 전환 애니메이션은 라디오 버튼의 :checked 상태나 role="tablist" 버튼만으로 탭 사이를 오가며 인디케이터가 미끄러지고 패널이 바뀌는 동작을 말합니다. :checked 만으로 JS 없이 이 글의 데모 전부 보기


