01밑줄 인디케이터 슬라이드
라디오로 연결된 탭 세 개 중 하나를 고르면, 탭 아래 밑줄이 :checked 상태만으로 골라진 탭 밑까지 translateX 하여 미끄러집니다.
쓰임대시보드 상단 섹션 전환, 설정 화면 탭
:checkedtranslateX300ms

<main class="stage">
<div class="us is-demo">
<input type="radio" name="us-tab" id="us-1" class="us__radio" checked>
<input type="radio" name="us-tab" id="us-2" class="us__radio">
<input type="radio" name="us-tab" id="us-3" class="us__radio">
<div class="us__nav">
<label class="us__tab" for="us-1">개요</label>
<label class="us__tab" for="us-2">분석</label>
<label class="us__tab" for="us-3">설정</label>
<span class="us__indicator" aria-hidden="true"></span>
</div>
<div class="us__panels">
<p class="us__panel us__panel--1">오늘 방문자와 핵심 지표를 한눈에 봅니다.</p>
<p class="us__panel us__panel--2">기간별 추이를 표와 그래프로 비교합니다.</p>
<p class="us__panel us__panel--3">알림과 팀 권한을 이 탭에서 관리합니다.</p>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base; // 인디케이터가 미끄러지는 시간
$easing: $ease-spring; // 인디케이터 전환 이징 (실제 클릭)
$color: $subject-blue; // 인디케이터 색
@include stage($stage-ink);
.us { width: min(320px, 88vw); }
.us__radio { position: absolute; opacity: 0; pointer-events: none; }
.us__nav {
position: relative;
isolation: isolate; // ::after 트레일(z -1)이 인디케이터 뒤에만 그려지게 (2026-09-15 한 겹)
display: grid;
grid-template-columns: repeat(3, 1fr);
}
.us__tab {
padding: $sp-3 $sp-2;
text-align: center;
font-weight: 600;
font-size: clamp(13px, 3.6vw, 15px);
line-height: 1;
font-family: inherit;
color: rgba(255, 247, 230, .6);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.us__indicator {
position: absolute; left: 0; bottom: 0;
width: calc(100% / 3); height: 3px;
background: $color;
border-radius: $r-pill;
transform: translateX(0);
transition: transform $duration $easing;
}
// 한 겹(관성 오버슛 + 트레일): ::after 만 추가 — index.html·레이아웃 불변.
// 트레일은 인디케이터 자신이 아니라 .us__nav(고정 컨테이너)의 ::after 로 둔다 — 만약 인디케이터
// 자신의 ::after 로 두면 부모의 진행 중인 transform 위에 트레일 자신의 transform 이 겹쳐 더해져
// (중첩 transform 합성) 이동거리가 배로 튄다. nav 는 움직이지 않으므로 트레일만의 절대 좌표
// keyframes 가 그대로 유효하다. 정적(:checked) 전환에서는 .is-demo 스코프 밖이라 animation 이
// 전혀 안 걸려 opacity 0 그대로 안 보인다. 트레일 색은 $color 파생 rgba 만 쓴다.
.us__nav::after {
content: "";
position: absolute; left: 0; bottom: 0;
z-index: -1;
width: calc(100% / 3); height: 3px;
border-radius: $r-pill;
background: rgba($color, .35);
filter: blur(2px);
opacity: 0;
}
// ── 실제 동작: :checked 만으로 인디케이터가 옮겨간다 ──────────────
#us-1:checked ~ .us__nav .us__indicator { transform: translateX(0%); }
#us-2:checked ~ .us__nav .us__indicator { transform: translateX(100%); }
#us-3:checked ~ .us__nav .us__indicator { transform: translateX(200%); }
#us-1:checked ~ .us__nav label[for="us-1"],
#us-2:checked ~ .us__nav label[for="us-2"],
#us-3:checked ~ .us__nav label[for="us-3"] { color: $subject-cream; }
// 패널 세 개를 같은 grid 칸에 포개 두면 인디케이터와 함께 바뀌어도 자리가 안 흔들린다
.us__panels { position: relative; display: grid; margin-top: $sp-4; min-height: 40px; }
.us__panel {
grid-area: 1 / 1;
margin: 0;
color: rgba(255, 247, 230, .82);
font-weight: 400;
font-size: clamp(12px, 3.2vw, 13px);
line-height: 1.5;
font-family: inherit;
opacity: 0;
transition: opacity $duration $easing;
}
#us-1:checked ~ .us__panels .us__panel--1,
#us-2:checked ~ .us__panels .us__panel--2,
#us-3:checked ~ .us__panels .us__panel--3 { opacity: 1; }
// ── 데모 루프: 클릭 전까지 세 탭을 스스로 오간다 (인디케이터·패널이 같은 박자) ──────────────
// us-loop → us-loop-inertia: 33%/66% 도착 전에 살짝 지나쳤다가(108%/216%) 관성처럼 되돌아와 정착한다.
.us.is-demo .us__indicator { animation: us-loop-inertia $dur-loop $easing infinite; }
// 트레일은 인디케이터와 같은 이동을 120ms 늦게 따라오다 도착 뒤 옅어지며 사라진다.
.us.is-demo .us__nav::after { animation: us-trail-loop $dur-loop $easing infinite; animation-delay: 120ms; }
.us.is-demo .us__panel--1 { animation: us-panel-1 $dur-loop $easing infinite; }
.us.is-demo .us__panel--2 { animation: us-panel-2 $dur-loop $easing infinite; }
.us.is-demo .us__panel--3 { animation: us-panel-3 $dur-loop $easing infinite; }
@keyframes us-loop-inertia {
0%, 15% { transform: translateX(0%); }
30% { transform: translateX(108%); }
33%, 48% { transform: translateX(100%); }
63% { transform: translateX(216%); }
66%, 81% { transform: translateX(200%); }
100% { transform: translateX(0%); }
}
// 트레일 전용: 같은 이동 경로 + opacity 를 얹어 "따라오다 옅어지는" 잔상으로 읽히게 한다.
@keyframes us-trail-loop {
0%, 15% { transform: translateX(0%); opacity: 0; }
24% { opacity: .35; }
30% { transform: translateX(108%); opacity: .35; }
33%, 40% { transform: translateX(100%); opacity: .35; }
48% { transform: translateX(100%); opacity: 0; }
63% { transform: translateX(216%); opacity: .35; }
66%, 73% { transform: translateX(200%); opacity: .35; }
81% { transform: translateX(200%); opacity: 0; }
100% { transform: translateX(0%); opacity: 0; }
}
@keyframes us-panel-1 { 0%, 15% { opacity: 1; } 30%, 100% { opacity: 0; } }
@keyframes us-panel-2 { 0%, 30% { opacity: 0; } 45%, 63% { opacity: 1; } 78%, 100% { opacity: 0; } }
@keyframes us-panel-3 { 0%, 63% { opacity: 0; } 78%, 96% { opacity: 1; } 100% { opacity: 0; } }
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.us__indicator, .us__panel { transition: none; }
.us.is-demo .us__indicator,
.us.is-demo .us__nav::after,
.us.is-demo .us__panel--1,
.us.is-demo .us__panel--2,
.us.is-demo .us__panel--3 { animation: none !important; }
.us__nav::after { 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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.us {
width: min(320px, 88vw);
}
.us__radio {
position: absolute;
opacity: 0;
pointer-events: none;
}
.us__nav {
position: relative;
isolation: isolate;
display: grid;
grid-template-columns: repeat(3, 1fr);
}
.us__tab {
padding: 12px 8px;
text-align: center;
font-weight: 600;
font-size: clamp(13px, 3.6vw, 15px);
line-height: 1;
font-family: inherit;
color: rgba(255, 247, 230, 0.6);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.us__indicator {
position: absolute;
left: 0;
bottom: 0;
width: 33.3333333333%;
height: 3px;
background: #2f6df6;
border-radius: 999px;
transform: translateX(0);
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.us__nav::after {
content: "";
position: absolute;
left: 0;
bottom: 0;
z-index: -1;
width: 33.3333333333%;
height: 3px;
border-radius: 999px;
background: rgba(47, 109, 246, 0.35);
filter: blur(2px);
opacity: 0;
}
#us-1:checked ~ .us__nav .us__indicator {
transform: translateX(0%);
}
#us-2:checked ~ .us__nav .us__indicator {
transform: translateX(100%);
}
#us-3:checked ~ .us__nav .us__indicator {
transform: translateX(200%);
}
#us-1:checked ~ .us__nav label[for=us-1],
#us-2:checked ~ .us__nav label[for=us-2],
#us-3:checked ~ .us__nav label[for=us-3] {
color: #fff7e6;
}
.us__panels {
position: relative;
display: grid;
margin-top: 16px;
min-height: 40px;
}
.us__panel {
grid-area: 1/1;
margin: 0;
color: rgba(255, 247, 230, 0.82);
font-weight: 400;
font-size: clamp(12px, 3.2vw, 13px);
line-height: 1.5;
font-family: inherit;
opacity: 0;
transition: opacity 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
#us-1:checked ~ .us__panels .us__panel--1,
#us-2:checked ~ .us__panels .us__panel--2,
#us-3:checked ~ .us__panels .us__panel--3 {
opacity: 1;
}
.us.is-demo .us__indicator {
animation: us-loop-inertia 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.us.is-demo .us__nav::after {
animation: us-trail-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
animation-delay: 120ms;
}
.us.is-demo .us__panel--1 {
animation: us-panel-1 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.us.is-demo .us__panel--2 {
animation: us-panel-2 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.us.is-demo .us__panel--3 {
animation: us-panel-3 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes us-loop-inertia {
0%, 15% {
transform: translateX(0%);
}
30% {
transform: translateX(108%);
}
33%, 48% {
transform: translateX(100%);
}
63% {
transform: translateX(216%);
}
66%, 81% {
transform: translateX(200%);
}
100% {
transform: translateX(0%);
}
}
@keyframes us-trail-loop {
0%, 15% {
transform: translateX(0%);
opacity: 0;
}
24% {
opacity: 0.35;
}
30% {
transform: translateX(108%);
opacity: 0.35;
}
33%, 40% {
transform: translateX(100%);
opacity: 0.35;
}
48% {
transform: translateX(100%);
opacity: 0;
}
63% {
transform: translateX(216%);
opacity: 0.35;
}
66%, 73% {
transform: translateX(200%);
opacity: 0.35;
}
81% {
transform: translateX(200%);
opacity: 0;
}
100% {
transform: translateX(0%);
opacity: 0;
}
}
@keyframes us-panel-1 {
0%, 15% {
opacity: 1;
}
30%, 100% {
opacity: 0;
}
}
@keyframes us-panel-2 {
0%, 30% {
opacity: 0;
}
45%, 63% {
opacity: 1;
}
78%, 100% {
opacity: 0;
}
}
@keyframes us-panel-3 {
0%, 63% {
opacity: 0;
}
78%, 96% {
opacity: 1;
}
100% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.us__indicator, .us__panel {
transition: none;
}
.us.is-demo .us__indicator,
.us.is-demo .us__nav::after,
.us.is-demo .us__panel--1,
.us.is-demo .us__panel--2,
.us.is-demo .us__panel--3 {
animation: none !important;
}
.us__nav::after {
opacity: 0;
}
}// 이동은 :checked 만으로 이미 동작한다. 실제 클릭이 오면 데모 루프(.is-demo)만 뗀다.
document.querySelectorAll('.us__radio').forEach(function (r) {
r.addEventListener('pointerdown', function () { document.querySelector('.us').classList.remove('is-demo'); });
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./UnderlineSlide.module.scss";
const TABS = [
{ label: "개요", panel: "오늘 방문자와 핵심 지표를 한눈에 봅니다." },
{ label: "분석", panel: "기간별 추이를 표와 그래프로 비교합니다." },
{ label: "설정", panel: "알림과 팀 권한을 이 탭에서 관리합니다." },
];
export type UnderlineSlideProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string; "--i"?: number };
export default function UnderlineSlide({ duration = 300, easing = "cubic-bezier(.2,.8,.2,1)", color = "#2f6df6" }: UnderlineSlideProps) {
const [active, setActive] = useState(0);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color, "--i": active };
return (
<div className={styles.us} style={vars}>
<div className={styles.nav} role="tablist">
{TABS.map((t, i) => (
<button key={t.label} type="button" role="tab" aria-selected={i === active}
className={styles.tab} onClick={() => setActive(i)}>
{t.label}
</button>
))}
<span className={styles.indicator} aria-hidden="true" />
</div>
<p className={styles.panel}>{TABS[active].panel}</p>
</div>
);
}01 밑줄 인디케이터 슬라이드 — 측정 누적면적 2.301% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · CSS 탭 메뉴 전환 애니메이션 9종 — JS 없이, CSS만으로
이 데모가 속한 글
CSS 탭 메뉴 전환 애니메이션 9종 — JS 없이, CSS만으로CSS 탭 메뉴 전환 애니메이션은 라디오 버튼의 :checked 상태나 role="tablist" 버튼만으로 탭 사이를 오가며 인디케이터가 미끄러지고 패널이 바뀌는 동작을 말합니다. :checked 만으로 JS 없이 이 글의 데모 전부 보기


