08탭 인디케이터 슬라이드
탭을 클릭하면 밑줄 인디케이터가 이전 탭에서 다음 탭 위치까지 폭과 위치를 함께 보간하며 미끄러진다.
쓰임설정 페이지 탭, 대시보드 카테고리 탭
transform 슬라이드width 보간0.25초

<main class="stage">
<nav class="tabs is-demo" role="tablist">
<button class="tabs__item is-active" type="button" role="tab" aria-selected="true">개요</button>
<button class="tabs__item" type="button" role="tab" aria-selected="false">설정</button>
<button class="tabs__item" type="button" role="tab" aria-selected="false">결제</button>
<span class="tabs__indicator" aria-hidden="true"></span>
</nav>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base; // 인디케이터가 옮겨가는 시간
$easing: $ease-spring; // 이동 곡선
$color: $subject-blue; // 인디케이터·활성 탭 색
// ── 데모 무대 ──────────────────────────────────────────
@include stage($stage-paper);
.tabs {
position: relative;
display: grid;
grid-template-columns: repeat(3, 1fr); // 세 칸을 똑같은 폭으로 — 그래야 translateX 배수만으로 옮겨간다
width: min(240px, 84vw);
background: $subject-cream;
border-radius: $r-control;
padding: 4px;
--i: 0; // 실제 클릭이 여기에 인덱스를 써 넣는다
&__item {
position: relative;
z-index: 1;
border: 0;
background: transparent;
padding: clamp(9px, 3vw, 12px) 4px;
font-weight: 700;
font-size: clamp(12px, 3.4vw, 14px);
line-height: 1;
font-family: inherit;
color: rgba($stage-ink, .72); // 흐린 글자는 알파가 아니라 색으로 — .72 가 크림 면 위 6.9:1
cursor: pointer;
&.is-active { color: $subject-blue-deep; } // 색은 상태값 — 트랜지션 없이 즉시. 크림 면 위 파란 글자는 deep(5.98:1)
}
&__indicator {
position: absolute;
left: 4px; bottom: 4px;
width: calc((100% - 8px) / 3);
height: 3px;
border-radius: $r-pill;
background: $color;
transform: translateX(calc(var(--i, 0) * 100%));
transition: transform $duration $easing;
}
&.is-demo &__indicator { animation: tab-loop $dur-loop $easing infinite; }
}
@keyframes tab-loop {
0%, 20% { transform: translateX(0%); }
33%, 53% { transform: translateX(100%); }
66%, 86% { transform: translateX(200%); }
100% { transform: translateX(0%); }
}
// ── 접근성: 움직임 줄이기 설정이면 즉시 전환, 첫 탭에 고정 ────────
@media (prefers-reduced-motion: reduce) {
.tabs__indicator { transition: none; }
.tabs.is-demo .tabs__indicator { 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: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.tabs {
position: relative;
display: grid;
grid-template-columns: repeat(3, 1fr);
width: min(240px, 84vw);
background: #fff7e6;
border-radius: 12px;
padding: 4px;
--i: 0;
}
.tabs__item {
position: relative;
z-index: 1;
border: 0;
background: transparent;
padding: clamp(9px, 3vw, 12px) 4px;
font-weight: 700;
font-size: clamp(12px, 3.4vw, 14px);
line-height: 1;
font-family: inherit;
color: rgba(23, 20, 26, 0.72);
cursor: pointer;
}
.tabs__item.is-active {
color: #0a4fe8;
}
.tabs__indicator {
position: absolute;
left: 4px;
bottom: 4px;
width: calc((100% - 8px) / 3);
height: 3px;
border-radius: 999px;
background: #2f6df6;
transform: translateX(calc(var(--i, 0) * 100%));
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.tabs.is-demo .tabs__indicator {
animation: tab-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes tab-loop {
0%, 20% {
transform: translateX(0%);
}
33%, 53% {
transform: translateX(100%);
}
66%, 86% {
transform: translateX(200%);
}
100% {
transform: translateX(0%);
}
}
@media (prefers-reduced-motion: reduce) {
.tabs__indicator {
transition: none;
}
.tabs.is-demo .tabs__indicator {
animation: none !important;
}
}// 실제 클릭: 누른 탭으로 인디케이터가 옮겨간다. 데모(.is-demo) 루프는 클릭하면 멈춘다.
var tabs = document.querySelectorAll('.tabs__item');
var nav = document.querySelector('.tabs');
tabs.forEach(function (t, i) {
t.addEventListener('click', function () {
nav.classList.remove('is-demo');
tabs.forEach(function (x) { x.classList.remove('is-active'); x.setAttribute('aria-selected', 'false'); });
t.classList.add('is-active');
t.setAttribute('aria-selected', 'true');
nav.style.setProperty('--i', i);
});
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./TabIndicatorSlide.module.scss";
export type TabIndicatorSlideProps = {
tabs: string[];
/** 인디케이터가 옮겨가는 시간 (기본 300ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--i"?: number };
export default function TabIndicatorSlide({
tabs,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: TabIndicatorSlideProps) {
const [active, setActive] = useState(0);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--i": active };
return (
<nav className={styles.tabs} role="tablist" style={{ ...vars, ["--color" as string]: color }}>
{tabs.map((label, i) => (
<button
key={label}
type="button"
role="tab"
aria-selected={i === active}
className={i === active ? `${styles.item} ${styles.active}` : styles.item}
onClick={() => setActive(i)}
>
{label}
</button>
))}
<span className={styles.indicator} style={{ width: `calc((100% - 8px) / ${tabs.length})` }} aria-hidden="true" />
</nav>
);
}08 탭 인디케이터 슬라이드 — 측정 누적면적 0.5% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 바이브코딩 마이크로 인터랙션 9종 — 한 줄 복붙으로
이 데모가 속한 글
바이브코딩 마이크로 인터랙션 9종 — 한 줄 복붙으로마이크로 인터랙션은 클릭 한 번, 저장 한 번처럼 아주 작은 동작에 화면이 곧바로 반응해 주는 짧은 피드백입니다. 아홉 개 중 하나(04)는 서버 응답을 기다리지 않고 먼저 화면부터 바꿨다가, 실패했다고 가정하면 스 이 글의 데모 전부 보기


