02Slide Panel an Index-difference Value Steers
The sign of (new tab index minus previous tab index) decides the slide direction, and when its absolute value exceeds one the travel distance grows by the number of tabs skipped.
Best forDashboard sub-tabs and mobile onboarding steps
translateX()STEP_MS인덱스 부호

<main class="stage">
<div class="sp is-demo" id="sp">
<div class="sp__tabs" role="tablist" aria-label="Step tabs">
<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">Step 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">Step 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">Step 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">Step 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">Move <b id="spDist">0</b>cells · direction <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 Slide Panel an Index-difference Value Steers — Measured area 6.432% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · 9 CSS Tab Panel Transition Patterns to Copy
The post this demo lives in
9 CSS Tab Panel Transition Patterns to CopyA css tab panel roundup: Height-morph times its stretch by the pixel gap between panels; stack shrinks by index distance See all demos in this post
If you like this effect - similar demos
Pickers03 Keyboard arrow select — the selected star's order value increases or decreases by one for each pressed direction valueStar Rating UI: 9 Interactive Rating Patterns
Pickers03 Keyboard-arrow value toggle — each time a direction value changes, a highlighted-cell value moves across the gridDate Picker UI: 9 Popover & Calendar Patterns
Pickers08 Automatic Flight Seat Assignment That Weighs Window, Aisle and Exit Row RulesSeat Selection UI: 9 Scenes That Score Every View