02インデックス差の値が方向を決めるスライドパネル
新しく押したタブの番号から直前のタブの番号を引いた値の符号でスライド方向を決め、その絶対値が1を超えるとスキップしたタブ数だけ移動距離も伸びる。
使いどころダッシュボードのサブタブ・モバイルオンボーディング
translateX()STEP_MS인덱스 부호

<main class="stage">
<div class="sp is-demo" id="sp">
<div class="sp__tabs" role="tablist" aria-label="ステップタブ">
<button type="button" class="sp__tab is-active" role="tab" id="sp-tab-0" aria-selected="true" aria-controls="sp-panel-0" data-idx="0">ステップ1</button>
<button type="button" class="sp__tab" role="tab" id="sp-tab-1" aria-selected="false" aria-controls="sp-panel-1" data-idx="1" tabindex="-1">ステップ2</button>
<button type="button" class="sp__tab" role="tab" id="sp-tab-2" aria-selected="false" aria-controls="sp-panel-2" data-idx="2" tabindex="-1">ステップ3</button>
<button type="button" class="sp__tab" role="tab" id="sp-tab-3" aria-selected="false" aria-controls="sp-panel-3" data-idx="3" tabindex="-1">ステップ4</button>
</div>
<div class="sp__viewport" id="spViewport">
<div class="sp__track" id="spTrack">
<div class="sp__panel" id="sp-panel-0" role="tabpanel" aria-labelledby="sp-tab-0" tabindex="0"><span class="sp__num">1</span></div>
<div class="sp__panel" id="sp-panel-1" role="tabpanel" aria-labelledby="sp-tab-1" tabindex="0" aria-hidden="true"><span class="sp__num">2</span></div>
<div class="sp__panel" id="sp-panel-2" role="tabpanel" aria-labelledby="sp-tab-2" tabindex="0" aria-hidden="true"><span class="sp__num">3</span></div>
<div class="sp__panel" id="sp-panel-3" role="tabpanel" aria-labelledby="sp-tab-3" tabindex="0" aria-hidden="true"><span class="sp__num">4</span></div>
</div>
</div>
<p class="sp__meta" id="spMeta">移動 <b id="spDist">0</b>マス・方向 <b id="spDir">-</b></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: index.html select() 가 (새 인덱스 - 이전 인덱스) 의 부호로 방향을, 절댓값으로 건너뛴 칸
// 수를 구해 전환 시간(BASE_MS + (거리-1)*STEP_MS)을 늘린다 — 두 칸을 건너뛰면 한 칸보다 오래 미끄러진다.
@include stage($stage-yellow);
.sp { width: min(100%, 300px); min-width: 0; display: flex; flex-direction: column; gap: $sp-3; }
.sp__tabs { display: flex; gap: $sp-1; background: rgba($subject-ink, .08); padding: $sp-1; border-radius: $r-pill; }
.sp__tab {
flex: 1 1 0; border: none; background: transparent; border-radius: $r-pill; cursor: pointer;
padding: $sp-2 $sp-2; font-size: 11px; font-weight: 700; color: rgba($subject-ink, .72);
}
.sp__tab.is-active { background: $subject-ink; color: $stage-yellow; }
.sp__tab:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
.sp__viewport { overflow: hidden; min-width: 0; border-radius: $r-card; background: rgba($subject-ink, .06); }
.sp__track {
display: flex; will-change: transform;
transition: transform var(--sp-dur, #{$dur-base}) $ease-spring;
}
.sp__panel {
flex: 0 0 auto; box-sizing: border-box; height: 120px; display: grid; place-items: center;
}
.sp__num { font-size: 40px; font-weight: 800; color: $subject-ink; opacity: .82; }
.sp__meta { margin: 0; font-size: 11px; color: rgba($subject-ink, .78); text-align: center; }
.sp__meta b { color: $subject-ink; font-weight: 700; }
@media (max-width: 340px) {
.sp { width: 100%; }
.sp__panel { height: 96px; }
.sp__num { font-size: 32px; }
}
@media (prefers-reduced-motion: reduce) {
.sp__track { 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);
}
.sp {
width: min(100%, 300px);
min-width: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
.sp__tabs {
display: flex;
gap: 4px;
background: rgba(23, 20, 26, 0.08);
padding: 4px;
border-radius: 999px;
}
.sp__tab {
flex: 1 1 0;
border: none;
background: transparent;
border-radius: 999px;
cursor: pointer;
padding: 8px 8px;
font-size: 11px;
font-weight: 700;
color: rgba(23, 20, 26, 0.72);
}
.sp__tab.is-active {
background: #17141a;
color: #ffd23f;
}
.sp__tab:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.sp__viewport {
overflow: hidden;
min-width: 0;
border-radius: 16px;
background: rgba(23, 20, 26, 0.06);
}
.sp__track {
display: flex;
will-change: transform;
transition: transform var(--sp-dur, 300ms) cubic-bezier(0.2, 0.8, 0.2, 1);
}
.sp__panel {
flex: 0 0 auto;
box-sizing: border-box;
height: 120px;
display: grid;
place-items: center;
}
.sp__num {
font-size: 40px;
font-weight: 800;
color: #17141a;
opacity: 0.82;
}
.sp__meta {
margin: 0;
font-size: 11px;
color: rgba(23, 20, 26, 0.78);
text-align: center;
}
.sp__meta b {
color: #17141a;
font-weight: 700;
}
@media (max-width: 340px) {
.sp {
width: 100%;
}
.sp__panel {
height: 96px;
}
.sp__num {
font-size: 32px;
}
}
@media (prefers-reduced-motion: reduce) {
.sp__track {
transition: none;
}
}(function () {
var BASE_MS = 220; // 계산 겹 상수: 한 칸 이동의 기본 시간
var STEP_MS = 90; // 계산 겹 상수: 건너뛴 칸 하나마다 늘어나는 시간
var MAX_MS = 640;
var HOLD_MS = 700;
var COUNT = 4;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var root = document.getElementById('sp');
var viewport = document.getElementById('spViewport');
var track = document.getElementById('spTrack');
var distEl = document.getElementById('spDist');
var dirEl = document.getElementById('spDir');
var tabs = [0, 1, 2, 3].map(function (i) { return document.getElementById('sp-tab-' + i); });
var panels = [0, 1, 2, 3].map(function (i) { return document.getElementById('sp-panel-' + i); });
var isDemo = true;
var active = 0;
function human() {
if (isDemo) { root.classList.remove('is-demo'); isDemo = false; }
}
function select(idx) {
if (idx === active) return;
var delta = idx - active; // 계산 겹: 새 인덱스 - 이전 인덱스
var dir = delta > 0 ? 1 : -1; // 계산 겹: 부호가 슬라이드 방향을 정한다
var dist = Math.abs(delta); // 계산 겹: 건너뛴 칸 수
var dur = Math.min(MAX_MS, BASE_MS + (dist - 1) * STEP_MS); // 계산 겹: 건너뛴 칸이 많을수록 오래 이동한다
var w = viewport.clientWidth;
track.style.setProperty('--sp-dur', dur.toFixed(0) + 'ms');
track.style.transform = 'translateX(' + (-idx * w).toFixed(2) + 'px)';
tabs[active].classList.remove('is-active');
tabs[active].setAttribute('aria-selected', 'false');
tabs[active].setAttribute('tabindex', '-1');
panels[active].setAttribute('aria-hidden', 'true');
tabs[idx].classList.add('is-active');
tabs[idx].setAttribute('aria-selected', 'true');
tabs[idx].setAttribute('tabindex', '0');
panels[idx].setAttribute('aria-hidden', 'false');
distEl.textContent = String(dist);
dirEl.textContent = dir > 0 ? '→' : '←';
active = idx;
}
function place(initial) {
var w = viewport.clientWidth; // 계산 겹: 패널 폭은 실측 뷰포트 폭 — 짐작 px 를 쓰지 않는다
panels.forEach(function (p) { p.style.width = w + 'px'; });
if (initial) track.style.transition = 'none'; // 최초 배치는 transition 없이 — 'none'→translateX(0) 전이가 애니메이션으로 잡혀 측정을 망가뜨린다
track.style.transform = 'translateX(' + (-active * w).toFixed(2) + 'px)';
if (initial) { track.getBoundingClientRect(); track.style.transition = ''; }
}
tabs.forEach(function (t, i) {
t.addEventListener('click', function () { human(); select(i); });
});
root.addEventListener('keydown', function (e) {
var idx = tabs.indexOf(document.activeElement);
if (idx === -1) return;
var next = null;
if (e.key === 'ArrowRight') next = (idx + 1) % COUNT;
else if (e.key === 'ArrowLeft') next = (idx - 1 + COUNT) % COUNT;
else if (e.key === 'Home') next = 0;
else if (e.key === 'End') next = COUNT - 1;
if (next === null) return;
e.preventDefault();
human();
tabs[next].focus({ preventScroll: true });
select(next);
});
window.addEventListener('resize', place);
var seq = [1, 3, 0, 2]; // 계산 겹 실연용 순서 — 1→4(건너뛰기 3) 처럼 다양한 거리를 자동 순환에서 보여준다
var seqPos = 0;
function loop() {
if (!isDemo) return;
seqPos = (seqPos + 1) % seq.length;
select(seq[seqPos]);
window.setTimeout(loop, HOLD_MS);
}
place(true);
if (!reduce) {
window.setTimeout(loop, HOLD_MS);
} else {
root.classList.remove('is-demo');
}
})();// TabSlideDirectionIndex — vanilla 판(run/394/demo/02-tab-panel-slide-direction-index/index.html) 의 React 포트.
// select() 가 (새 인덱스 - 이전 인덱스) 의 부호로 방향을, 절댓값으로 건너뛴 칸 수를 구해
// 전환 시간(BASE_MS + (거리-1)*STEP_MS)을 늘린다.
import { useEffect, useRef } from "react";
import styles from "./TabSlideDirectionIndex.module.scss";
type Box<T> = { current: T };
const BASE_MS = 220; // 계산 겹 상수: 한 칸 이동의 기본 시간
const STEP_MS = 90; // 계산 겹 상수: 건너뛴 칸 하나마다 늘어나는 시간
const MAX_MS = 640;
const HOLD_MS = 700;
const COUNT = 4;
const SEQ = [1, 3, 0, 2];
export default function TabSlideDirectionIndex() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const viewportRef = useRef(null) as unknown as Box<HTMLDivElement>;
const trackRef = useRef(null) as unknown as Box<HTMLDivElement>;
const distRef = useRef(null) as unknown as Box<HTMLElement>;
const dirRef = useRef(null) as unknown as Box<HTMLElement>;
const tabRefs = useRef([null, null, null, null]) as unknown as Box<(HTMLButtonElement | null)[]>;
const panelRefs = useRef([null, null, null, null]) as unknown as Box<(HTMLDivElement | null)[]>;
const timerRef = useRef(0) as unknown as Box<number>;
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let isDemo = true;
let active = 0;
let seqPos = 0;
function human() {
if (isDemo) { rootRef.current?.classList.remove(styles.isDemo); isDemo = false; }
}
function select(idx: number) {
if (idx === active) return;
const track = trackRef.current, viewport = viewportRef.current;
if (!track || !viewport) return;
const delta = idx - active; // 계산 겹: 새 인덱스 - 이전 인덱스
const dir = delta > 0 ? 1 : -1; // 계산 겹: 부호가 슬라이드 방향을 정한다
const dist = Math.abs(delta); // 계산 겹: 건너뛴 칸 수
const dur = Math.min(MAX_MS, BASE_MS + (dist - 1) * STEP_MS); // 계산 겹: 건너뛴 칸이 많을수록 오래 이동한다
const w = viewport.clientWidth;
track.style.setProperty("--sp-dur", dur.toFixed(0) + "ms");
track.style.transform = "translateX(" + (-idx * w).toFixed(2) + "px)";
const prevTab = tabRefs.current[active], nextTab = tabRefs.current[idx];
const prevPanel = panelRefs.current[active], nextPanel = panelRefs.current[idx];
prevTab?.classList.remove(styles.isActive);
prevTab?.setAttribute("aria-selected", "false");
prevTab?.setAttribute("tabindex", "-1");
prevPanel?.setAttribute("aria-hidden", "true");
nextTab?.classList.add(styles.isActive);
nextTab?.setAttribute("aria-selected", "true");
nextTab?.setAttribute("tabindex", "0");
nextPanel?.setAttribute("aria-hidden", "false");
if (distRef.current) distRef.current.textContent = String(dist);
if (dirRef.current) dirRef.current.textContent = dir > 0 ? "→" : "←";
active = idx;
}
function place(initial?: boolean) {
const viewport = viewportRef.current, track = trackRef.current;
if (!viewport || !track) return;
const w = viewport.clientWidth; // 계산 겹: 패널 폭은 실측 뷰포트 폭
panelRefs.current.forEach((p) => { if (p) p.style.width = w + "px"; });
if (initial) track.style.transition = "none"; // 최초 배치는 transition 없이 — 측정 훼손 방지
track.style.transform = "translateX(" + (-active * w).toFixed(2) + "px)";
if (initial) { track.getBoundingClientRect(); track.style.transition = ""; }
}
function onKeydown(e: KeyboardEvent) {
const idx = tabRefs.current.indexOf(document.activeElement as HTMLButtonElement);
if (idx === -1) return;
let next: number | null = null;
if (e.key === "ArrowRight") next = (idx + 1) % COUNT;
else if (e.key === "ArrowLeft") next = (idx - 1 + COUNT) % COUNT;
else if (e.key === "Home") next = 0;
else if (e.key === "End") next = COUNT - 1;
if (next === null) return;
e.preventDefault();
human();
tabRefs.current[next]?.focus({ preventScroll: true });
select(next);
}
function onTabClick(i: number) { return () => { human(); select(i); }; }
const clickHandlers = [0, 1, 2, 3].map(onTabClick);
tabRefs.current.forEach((t, i) => t?.addEventListener("click", clickHandlers[i]));
rootRef.current?.addEventListener("keydown", onKeydown);
function onResize() { place(); }
window.addEventListener("resize", onResize);
function loop() {
if (!isDemo) return;
seqPos = (seqPos + 1) % SEQ.length;
select(SEQ[seqPos]);
timerRef.current = window.setTimeout(loop, HOLD_MS);
}
place(true);
if (!reduce) {
timerRef.current = window.setTimeout(loop, HOLD_MS);
} else {
rootRef.current?.classList.remove(styles.isDemo);
}
return () => {
window.clearTimeout(timerRef.current);
window.removeEventListener("resize", onResize);
tabRefs.current.forEach((t, i) => t?.removeEventListener("click", clickHandlers[i]));
rootRef.current?.removeEventListener("keydown", onKeydown);
};
}, []);
const labels = ["1단계", "2단계", "3단계", "4단계"];
return (
<main className={styles.stage}>
<div className={styles.sp + " " + styles.isDemo} ref={rootRef}>
<div className={styles.tabs} role="tablist" aria-label="단계 탭">
{labels.map((label, i) => (
<button
key={label}
type="button"
className={styles.tab + (i === 0 ? " " + styles.isActive : "")}
role="tab"
aria-selected={i === 0 ? "true" : "false"}
tabIndex={i === 0 ? 0 : -1}
ref={(el: HTMLButtonElement | null) => { tabRefs.current[i] = el; }}
>{label}</button>
))}
</div>
<div className={styles.viewport} ref={viewportRef}>
<div className={styles.track} ref={trackRef}>
{[1, 2, 3, 4].map((n, i) => (
<div
key={n}
className={styles.panel}
role="tabpanel"
tabIndex={0}
aria-hidden={i === 0 ? "false" : "true"}
ref={(el: HTMLDivElement | null) => { panelRefs.current[i] = el; }}
>
<span className={styles.num}>{n}</span>
</div>
))}
</div>
</div>
<p className={styles.meta}>
이동 <b ref={distRef}>0</b>칸 · 방향 <b ref={dirRef}>-</b>
</p>
</div>
</main>
);
}02 インデックス差の値が方向を決めるスライドパネル — 計測 累積面積 6.432% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · css タブ 切り替え ふわっと 9パターン — コードそのまま
このデモが属する記事
css タブ 切り替え ふわっと 9パターン — コードそのままcss タブ 切り替え ふわっと の9パターンは、それぞれ違う値を計算の起点にしています。高さモーフは2枚のパネルの実測ピクセル差で伸びる時間を決め、スタック型はアクティブタブとのインデックス距離で縮小率を決め、弾性スライドはバネ定数で弾ん この記事のデモをすべて見る


