04Panel slide
Three panels sit side by side and the outer track shifts by translateX(-33.333% × n) as a whole, bringing only the chosen tab's panel into view.
Best forOnboarding step tabs, multi-step form transitions
translateXoverflow:hidden300ms

<main class="stage">
<div class="sp is-demo">
<input type="radio" name="sp-tab" id="sp-1" class="sp__radio" checked>
<input type="radio" name="sp-tab" id="sp-2" class="sp__radio">
<input type="radio" name="sp-tab" id="sp-3" class="sp__radio">
<div class="sp__nav">
<label class="sp__tab" for="sp-1">Step 1</label>
<label class="sp__tab" for="sp-2">Step 2</label>
<label class="sp__tab" for="sp-3">Step 3</label>
<span class="sp__indicator" aria-hidden="true"></span>
</div>
<div class="sp__viewport">
<div class="sp__track">
<p class="sp__panel">Enter a shipping address.</p>
<p class="sp__panel">Choose a payment method.</p>
<p class="sp__panel">Review the order and complete it.</p>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base; // 패널 트랙이 밀리는 시간
$easing: $ease-out; // 트랙·밑줄 공용 이징 (트랙은 이 한 겹에서 건드리지 않는다)
$color: $subject-blue; // 활성 탭 밑줄 색
@include stage($stage-paper);
.sp { width: min(300px, 86vw); }
.sp__radio { position: absolute; opacity: 0; pointer-events: none; }
.sp__nav {
position: relative;
isolation: isolate; // ::after 트레일(z -1)이 인디케이터 뒤에만 그려지게 (2026-09-15 한 겹)
display: grid;
grid-template-columns: repeat(3, 1fr);
margin-bottom: $sp-3;
border-bottom: 1px solid rgba(23, 20, 26, .12);
}
.sp__tab {
padding-bottom: $sp-2;
text-align: center;
font-weight: 600;
font-size: clamp(12px, 3.4vw, 14px);
line-height: 1;
font-family: inherit;
color: $subject-ink;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
// 탭 셋 아래 밑줄 하나가 옮겨간다 — 트랙과 같은 :checked·같은 박자라 어긋나지 않는다
.sp__indicator {
position: absolute; left: 0; bottom: -1px;
width: calc(100% / 3); height: 2px;
background: $color;
transform: translateX(0%);
transition: transform $duration $easing;
}
// 한 겹(관성+트레일)은 인디케이터(밑줄)에만 — ::after 만 추가, index.html·트랙 불변.
// 트레일은 인디케이터 자신이 아니라 .sp__nav(고정 컨테이너)의 ::after 로 둔다 — 인디케이터 자신의
// ::after 로 두면 부모의 진행 중인 transform 위에 트레일 자신의 transform 이 겹쳐 더해져(중첩
// transform 합성) 이동거리가 배로 튄다. nav 는 움직이지 않으므로 트레일만의 절대 좌표 keyframes
// 가 그대로 유효하다. 정적(:checked) 전환에서는 .is-demo 스코프 밖이라 animation 이 안 걸려
// opacity 0 그대로다.
.sp__nav::after {
content: "";
position: absolute; left: 0; bottom: -1px;
z-index: -1;
width: calc(100% / 3); height: 2px;
background: rgba($color, .35);
filter: blur(2px);
opacity: 0;
}
#sp-1:checked ~ .sp__nav .sp__indicator { transform: translateX(0%); }
#sp-2:checked ~ .sp__nav .sp__indicator { transform: translateX(100%); }
#sp-3:checked ~ .sp__nav .sp__indicator { transform: translateX(200%); }
// 패널 세 장을 나란히 두고 트랙 전체를 옮긴다 — width 가 아니라 transform 만 쓴다
.sp__viewport { overflow: hidden; }
.sp__track {
display: flex;
width: 300%;
transform: translateX(0%);
transition: transform $duration $easing;
}
.sp__panel {
flex: 0 0 calc(100% / 3);
margin: 0;
box-sizing: border-box;
padding-right: $sp-4;
color: rgba(23, 20, 26, .8);
font-weight: 400;
font-size: clamp(12px, 3.2vw, 13px);
line-height: 1.5;
font-family: inherit;
}
// ── 실제 동작: :checked 만으로 트랙이 밀려난다 ──────────────
#sp-1:checked ~ .sp__viewport .sp__track { transform: translateX(0%); }
#sp-2:checked ~ .sp__viewport .sp__track { transform: translateX(-33.333%); }
#sp-3:checked ~ .sp__viewport .sp__track { transform: translateX(-66.667%); }
// ── 데모 루프: 클릭 전까지 세 단계를 스스로 오간다 (밑줄·트랙이 같은 박자) ──────────────
// 트랙(.sp__track)은 건드리지 않는다 — 콘텐츠 이동에 오버슛을 주면 어색하다. sp-loop 원본 그대로.
.sp.is-demo .sp__track { animation: sp-loop $dur-loop $easing infinite; }
// 밑줄(인디케이터)만 오버슛 보강 — sp-indicator-loop 안에 30%/63% 지나침 스텝을 추가(이름은 유지)
.sp.is-demo .sp__indicator { animation: sp-indicator-loop $dur-loop $easing infinite; }
.sp.is-demo .sp__nav::after { animation: sp-trail-loop $dur-loop $easing infinite; animation-delay: 120ms; }
@keyframes sp-loop {
0%, 15% { transform: translateX(0%); }
33%, 48% { transform: translateX(-33.333%); }
66%, 81% { transform: translateX(-66.667%); }
100% { transform: translateX(0%); }
}
@keyframes sp-indicator-loop {
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%); }
}
// 트레일 전용 — 같은 경로를 120ms 늦게 쫓아가며 옅어진다 (1번과 동일 패턴)
@keyframes sp-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; }
}
@media (prefers-reduced-motion: reduce) {
.sp__track, .sp__indicator { transition: none; }
.sp.is-demo .sp__track,
.sp.is-demo .sp__indicator,
.sp.is-demo .sp__nav::after { animation: none !important; }
.sp__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: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sp {
width: min(300px, 86vw);
}
.sp__radio {
position: absolute;
opacity: 0;
pointer-events: none;
}
.sp__nav {
position: relative;
isolation: isolate;
display: grid;
grid-template-columns: repeat(3, 1fr);
margin-bottom: 12px;
border-bottom: 1px solid rgba(23, 20, 26, 0.12);
}
.sp__tab {
padding-bottom: 8px;
text-align: center;
font-weight: 600;
font-size: clamp(12px, 3.4vw, 14px);
line-height: 1;
font-family: inherit;
color: #17141a;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.sp__indicator {
position: absolute;
left: 0;
bottom: -1px;
width: 33.3333333333%;
height: 2px;
background: #2f6df6;
transform: translateX(0%);
transition: transform 300ms ease-out;
}
.sp__nav::after {
content: "";
position: absolute;
left: 0;
bottom: -1px;
z-index: -1;
width: 33.3333333333%;
height: 2px;
background: rgba(47, 109, 246, 0.35);
filter: blur(2px);
opacity: 0;
}
#sp-1:checked ~ .sp__nav .sp__indicator {
transform: translateX(0%);
}
#sp-2:checked ~ .sp__nav .sp__indicator {
transform: translateX(100%);
}
#sp-3:checked ~ .sp__nav .sp__indicator {
transform: translateX(200%);
}
.sp__viewport {
overflow: hidden;
}
.sp__track {
display: flex;
width: 300%;
transform: translateX(0%);
transition: transform 300ms ease-out;
}
.sp__panel {
flex: 0 0 33.3333333333%;
margin: 0;
box-sizing: border-box;
padding-right: 16px;
color: rgba(23, 20, 26, 0.8);
font-weight: 400;
font-size: clamp(12px, 3.2vw, 13px);
line-height: 1.5;
font-family: inherit;
}
#sp-1:checked ~ .sp__viewport .sp__track {
transform: translateX(0%);
}
#sp-2:checked ~ .sp__viewport .sp__track {
transform: translateX(-33.333%);
}
#sp-3:checked ~ .sp__viewport .sp__track {
transform: translateX(-66.667%);
}
.sp.is-demo .sp__track {
animation: sp-loop 2s ease-out infinite;
}
.sp.is-demo .sp__indicator {
animation: sp-indicator-loop 2s ease-out infinite;
}
.sp.is-demo .sp__nav::after {
animation: sp-trail-loop 2s ease-out infinite;
animation-delay: 120ms;
}
@keyframes sp-loop {
0%, 15% {
transform: translateX(0%);
}
33%, 48% {
transform: translateX(-33.333%);
}
66%, 81% {
transform: translateX(-66.667%);
}
100% {
transform: translateX(0%);
}
}
@keyframes sp-indicator-loop {
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 sp-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;
}
}
@media (prefers-reduced-motion: reduce) {
.sp__track, .sp__indicator {
transition: none;
}
.sp.is-demo .sp__track,
.sp.is-demo .sp__indicator,
.sp.is-demo .sp__nav::after {
animation: none !important;
}
.sp__nav::after {
opacity: 0;
}
}// 이동은 :checked 만으로 이미 동작한다. 실제 클릭이 오면 데모 루프(.is-demo)만 뗀다.
document.querySelectorAll('.sp__radio').forEach(function (r) {
r.addEventListener('pointerdown', function () { document.querySelector('.sp').classList.remove('is-demo'); });
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SlidePanel.module.scss";
const TABS = [
{ label: "1단계", panel: "배송지를 입력합니다." },
{ label: "2단계", panel: "결제 수단을 고릅니다." },
{ label: "3단계", panel: "주문을 확인하고 완료합니다." },
];
export type SlidePanelProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string; "--i"?: number };
export default function SlidePanel({ duration = 300, easing = "ease-out", color = "#2f6df6" }: SlidePanelProps) {
const [active, setActive] = useState(0);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color, "--i": active };
return (
<div className={styles.sp} 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>
))}
</div>
<div className={styles.viewport}>
<div className={styles.track}>
{TABS.map((t) => <p key={t.label} className={styles.panel}>{t.panel}</p>)}
</div>
</div>
</div>
);
}04 Panel slide — Measured area 2.853% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · 9 CSS Tab Switch Animations — No Library, Pure CSS
The post this demo lives in
9 CSS Tab Switch Animations — No Library, Pure CSSA css tab switch animation slides an indicator or swaps a panel when a different tab is picked. Seven of these nine run See all demos in this post
If you like this effect - similar demos
Transitions01 height:auto transition9 CSS Dropdown Menu Animation—No Library, Pure CSS
Transitions01 Active-indicator slide-morph toggle — when the selected tab changes, the highlight's position and width values shift together from the old spot to the new oneCSS Bottom Navigation Bar: 9 Transition Patterns
Transitions01 Default cross-fade9 CSS Page Transitions — No Library, Pure CSS