04스와이프 탭 전환
패널이 손가락 델타를 실시간으로 따라오다가 놓는 순간 남은 거리와 속도를 보고 마저 넘어가거나 제자리로 돌아오고, 탭 알약과 화살표 키도 같은 패널을 고릅니다.
쓰임주간·월간·연간처럼 같은 지표를 여러 각도로 보여 주는 모바일 통계 화면을 넘겨 쓰게 할 때
translateXthresholdroving tabindex

<main class="stage">
<div class="wt is-demo" id="wt-root">
<div class="wt__tabs" role="tablist" aria-label="통계 기간">
<span class="wt__pill" id="wt-pill" aria-hidden="true"></span>
<button class="wt__tab is-on" type="button" role="tab" id="wt-t0" aria-selected="true" aria-controls="wt-p0">주간</button>
<button class="wt__tab" type="button" role="tab" id="wt-t1" aria-selected="false" aria-controls="wt-p1" tabindex="-1">월간</button>
<button class="wt__tab" type="button" role="tab" id="wt-t2" aria-selected="false" aria-controls="wt-p2" tabindex="-1">연간</button>
</div>
<div class="wt__view" id="wt-view">
<div class="wt__track" id="wt-track">
<section class="wt__panel" role="tabpanel" id="wt-p0" aria-labelledby="wt-t0">
<strong class="wt__num">3.2만</strong>
<div class="wt__bars"><i style="height:34%"></i><i style="height:64%"></i><i style="height:48%"></i><i style="height:82%"></i><i style="height:58%"></i></div>
<span class="wt__cap">이번 주 방문</span>
</section>
<section class="wt__panel" role="tabpanel" id="wt-p1" aria-labelledby="wt-t1">
<strong class="wt__num">12.8만</strong>
<div class="wt__bars"><i style="height:52%"></i><i style="height:78%"></i><i style="height:66%"></i></div>
<span class="wt__cap">이번 달 방문</span>
</section>
<section class="wt__panel" role="tabpanel" id="wt-p2" aria-labelledby="wt-t2">
<strong class="wt__num">148만</strong>
<div class="wt__bars"><i style="height:40%"></i><i style="height:88%"></i><i style="height:60%"></i><i style="height:74%"></i></div>
<span class="wt__cap">올해 방문</span>
</section>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
.wt { width: min(252px, 86vw); }
.wt__tabs {
position: relative;
display: flex;
gap: $sp-1;
padding: $sp-1;
background: rgba(255, 255, 255, .07);
border-radius: $r-pill;
}
.wt__pill {
position: absolute;
top: $sp-1;
left: $sp-1;
width: 68px;
height: 28px;
background: $color;
border-radius: $r-pill;
transform: translateX(0);
transition: transform $duration $easing;
}
.wt__tab {
position: relative;
z-index: 1;
flex: 0 0 auto;
box-sizing: border-box;
width: 68px;
height: 28px;
padding: 0;
border: 0;
background: none;
border-radius: $r-pill;
font-family: inherit;
font-size: 11px; line-height: 28px; font-weight: 700;
color: rgba(255, 255, 255, .55);
cursor: pointer;
transition: color $dur-quick steps(1, end);
}
.wt__tab.is-on { color: #fff; }
.wt__tab:focus-visible { outline: 2px solid $subject-cream; outline-offset: -3px; }
.wt__view {
margin-top: $sp-3;
overflow: hidden;
border-radius: $r-card;
background: rgba(255, 255, 255, .05);
touch-action: pan-y;
}
.wt__track {
display: flex;
width: 100%;
transition: transform $duration $easing;
}
// 패널마다 바탕을 달리한다 — 같은 색이면 미는 중에 두 패널이 한 장처럼 뒤섞여 보인다
.wt__panel:nth-child(1) { background: rgba(47, 109, 246, .16); }
.wt__panel:nth-child(2) { background: rgba(255, 158, 187, .14); }
.wt__panel:nth-child(3) { background: rgba(76, 212, 166, .14); }
.wt__panel {
box-sizing: border-box;
flex: 0 0 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-2;
padding: $sp-3;
}
.wt__num {
font-size: 24px; line-height: 26px; font-weight: 800;
color: $subject-cream;
font-variant-numeric: tabular-nums;
}
.wt__bars {
display: flex;
align-items: flex-end;
gap: $sp-2;
width: 148px;
height: 44px;
}
.wt__bars i {
display: block;
flex: 1;
background: $color;
border-radius: $r-xs $r-xs 0 0;
}
.wt__bars i:nth-child(2n) { background: $subject-pink; }
.wt__cap {
font-size: 11px; line-height: 14px; font-weight: 600;
color: rgba(255, 255, 255, .5);
}
// ── 자동 루프: 주간 → 월간 → 연간 → 되돌아오기. 패널·알약·탭색이 같은 박자 ──
.wt.is-demo .wt__track { animation: wt-slide $dur-loop $easing infinite; }
@keyframes wt-slide {
0%, 14% { transform: translateX(0); }
24%, 40% { transform: translateX(-100%); }
50%, 66% { transform: translateX(-200%); }
90%, 100% { transform: translateX(0); }
}
.wt.is-demo .wt__pill { animation: wt-pill $dur-loop $easing infinite; }
@keyframes wt-pill {
0%, 14% { transform: translateX(0); }
24%, 40% { transform: translateX(72px); }
50%, 66% { transform: translateX(144px); }
90%, 100% { transform: translateX(0); }
}
.wt.is-demo .wt__tab { transition: none; }
.wt.is-demo .wt__tab:nth-child(2) { animation: wt-t0 $dur-loop steps(1, end) infinite; }
.wt.is-demo .wt__tab:nth-child(3) { animation: wt-t1 $dur-loop steps(1, end) infinite; }
.wt.is-demo .wt__tab:nth-child(4) { animation: wt-t2 $dur-loop steps(1, end) infinite; }
@keyframes wt-t0 { 0%, 23% { color: #fff; } 24%, 89% { color: rgba(255, 255, 255, .55); } 90%, 100% { color: #fff; } }
@keyframes wt-t1 { 0%, 23% { color: rgba(255, 255, 255, .55); } 24%, 49% { color: #fff; } 50%, 100% { color: rgba(255, 255, 255, .55); } }
@media (prefers-reduced-motion: reduce) {
.wt.is-demo .wt__track,
.wt.is-demo .wt__pill,
.wt.is-demo .wt__tab { animation: none !important; }
.wt__track, .wt__pill { transition: none; }
}
@keyframes wt-t2 { 0%, 49% { color: rgba(255, 255, 255, .55); } 50%, 89% { color: #fff; } 90%, 100% { color: rgba(255, 255, 255, .55); } }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);
}
.wt {
width: min(252px, 86vw);
}
.wt__tabs {
position: relative;
display: flex;
gap: 4px;
padding: 4px;
background: rgba(255, 255, 255, 0.07);
border-radius: 999px;
}
.wt__pill {
position: absolute;
top: 4px;
left: 4px;
width: 68px;
height: 28px;
background: #2f6df6;
border-radius: 999px;
transform: translateX(0);
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.wt__tab {
position: relative;
z-index: 1;
flex: 0 0 auto;
box-sizing: border-box;
width: 68px;
height: 28px;
padding: 0;
border: 0;
background: none;
border-radius: 999px;
font-family: inherit;
font-size: 11px;
line-height: 28px;
font-weight: 700;
color: rgba(255, 255, 255, 0.55);
cursor: pointer;
transition: color 220ms steps(1, end);
}
.wt__tab.is-on {
color: #fff;
}
.wt__tab:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: -3px;
}
.wt__view {
margin-top: 12px;
overflow: hidden;
border-radius: 16px;
background: rgba(255, 255, 255, 0.05);
touch-action: pan-y;
}
.wt__track {
display: flex;
width: 100%;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.wt__panel:nth-child(1) {
background: rgba(47, 109, 246, 0.16);
}
.wt__panel:nth-child(2) {
background: rgba(255, 158, 187, 0.14);
}
.wt__panel:nth-child(3) {
background: rgba(76, 212, 166, 0.14);
}
.wt__panel {
box-sizing: border-box;
flex: 0 0 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 12px;
}
.wt__num {
font-size: 24px;
line-height: 26px;
font-weight: 800;
color: #fff7e6;
font-variant-numeric: tabular-nums;
}
.wt__bars {
display: flex;
align-items: flex-end;
gap: 8px;
width: 148px;
height: 44px;
}
.wt__bars i {
display: block;
flex: 1;
background: #2f6df6;
border-radius: 4px 4px 0 0;
}
.wt__bars i:nth-child(2n) {
background: #ff9ebb;
}
.wt__cap {
font-size: 11px;
line-height: 14px;
font-weight: 600;
color: rgba(255, 255, 255, 0.5);
}
.wt.is-demo .wt__track {
animation: wt-slide 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes wt-slide {
0%, 14% {
transform: translateX(0);
}
24%, 40% {
transform: translateX(-100%);
}
50%, 66% {
transform: translateX(-200%);
}
90%, 100% {
transform: translateX(0);
}
}
.wt.is-demo .wt__pill {
animation: wt-pill 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes wt-pill {
0%, 14% {
transform: translateX(0);
}
24%, 40% {
transform: translateX(72px);
}
50%, 66% {
transform: translateX(144px);
}
90%, 100% {
transform: translateX(0);
}
}
.wt.is-demo .wt__tab {
transition: none;
}
.wt.is-demo .wt__tab:nth-child(2) {
animation: wt-t0 2s steps(1, end) infinite;
}
.wt.is-demo .wt__tab:nth-child(3) {
animation: wt-t1 2s steps(1, end) infinite;
}
.wt.is-demo .wt__tab:nth-child(4) {
animation: wt-t2 2s steps(1, end) infinite;
}
@keyframes wt-t0 {
0%, 23% {
color: #fff;
}
24%, 89% {
color: rgba(255, 255, 255, 0.55);
}
90%, 100% {
color: #fff;
}
}
@keyframes wt-t1 {
0%, 23% {
color: rgba(255, 255, 255, 0.55);
}
24%, 49% {
color: #fff;
}
50%, 100% {
color: rgba(255, 255, 255, 0.55);
}
}
@media (prefers-reduced-motion: reduce) {
.wt.is-demo .wt__track,
.wt.is-demo .wt__pill,
.wt.is-demo .wt__tab {
animation: none !important;
}
.wt__track, .wt__pill {
transition: none;
}
}
@keyframes wt-t2 {
0%, 49% {
color: rgba(255, 255, 255, 0.55);
}
50%, 89% {
color: #fff;
}
90%, 100% {
color: rgba(255, 255, 255, 0.55);
}
}// 실제 동작: 패널이 손가락을 따라오다가 놓으면 거리·속도로 확정. 탭·화살표 키도 같은 인덱스를 고른다.
var root = document.getElementById('wt-root');
var view = document.getElementById('wt-view');
var track = document.getElementById('wt-track');
var pill = document.getElementById('wt-pill');
var tabs = [].slice.call(document.querySelectorAll('.wt__tab'));
var W, at = 0;
function end() { W = view.clientWidth; }
end();
window.addEventListener('resize', end);
function paint(i, dx) {
track.style.transform = 'translateX(' + (-i * W + (dx || 0)) + 'px)';
pill.style.transform = 'translateX(' + (i * 72) + 'px)';
tabs.forEach(function (t, k) {
var on = k === i;
t.classList.toggle('is-on', on);
t.setAttribute('aria-selected', String(on));
t.tabIndex = on ? 0 : -1;
});
}
function go(i) { at = Math.max(0, Math.min(i, tabs.length - 1)); paint(at, 0); }
tabs.forEach(function (t, i) {
t.addEventListener('click', function () { root.classList.remove('is-demo'); go(i); });
});
document.querySelector('.wt__tabs').addEventListener('keydown', function (e) {
var k = e.key === 'ArrowRight' ? at + 1 : e.key === 'ArrowLeft' ? at - 1 : e.key === 'Home' ? 0 : e.key === 'End' ? tabs.length - 1 : at;
if (k !== at) {
root.classList.remove('is-demo');
go(k);
tabs[k].focus({ preventScroll: true });
e.preventDefault();
}
});
var x0 = 0, v = 0, lastX = 0, lastT = 0, dragging = false;
view.addEventListener('pointerdown', function (e) {
if (e.target.closest('.wt__tabs')) return;
root.classList.remove('is-demo');
dragging = true; x0 = e.clientX; lastX = e.clientX; lastT = Date.now(); v = 0;
view.setPointerCapture(e.pointerId);
track.style.transition = 'none';
});
view.addEventListener('pointermove', function (e) {
if (!dragging) return;
var dx = e.clientX - x0;
var now = Date.now();
if (now - lastT > 0) { v = (e.clientX - lastX) / Math.max(1, now - lastT); lastX = e.clientX; lastT = now; }
if ((at === 0 && dx > 0) || (at === tabs.length - 1 && dx < 0)) dx *= .3; // 끝에서는 저항
paint(at, dx);
});
function drop() {
if (!dragging) return;
dragging = false;
track.style.transition = '';
var dx = lastX - x0;
if (dx < -44 || v < -.45) go(at + 1);
else if (dx > 44 || v > .45) go(at - 1);
else go(at);
}
view.addEventListener('pointerup', drop);
view.addEventListener('pointercancel', drop);
paint(0, 0);import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SwipeTabs.module.scss";
export type SwipeTabsProps = {
tabs?: string[];
duration?: number;
easing?: string;
color?: string;
};
type PEvt = { clientX: number; clientY: number; pointerId: number; preventDefault(): void; currentTarget: { setPointerCapture(id: number): void } };
type KeyEvt = { key: string; preventDefault(): void };
type Box<T> = { current: T };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function SwipeTabs({
tabs = ["Weekly", "Monthly", "Yearly"],
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: SwipeTabsProps) {
const [at, setAt] = useState(0);
const [dx, setDx] = useState(0);
const viewRef = useRef<HTMLDivElement>(null);
const drag = useRef({ on: false, x0: 0, lastX: 0, lastT: 0, v: 0 }) as Box<{ on: boolean; x0: number; lastX: number; lastT: number; v: number }>;
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function go(i: number) {
setAt(Math.max(0, Math.min(i, tabs.length - 1)));
setDx(0);
}
function onDown(e: PEvt) {
drag.current = { on: true, x0: e.clientX, lastX: e.clientX, lastT: Date.now(), v: 0 };
e.currentTarget.setPointerCapture(e.pointerId);
}
function onMove(e: PEvt) {
if (!drag.current.on) return;
const now = Date.now();
if (now - drag.current.lastT > 0) {
drag.current.v = (e.clientX - drag.current.lastX) / Math.max(1, now - drag.current.lastT);
drag.current.lastX = e.clientX;
drag.current.lastT = now;
}
let d = e.clientX - drag.current.x0;
if ((at === 0 && d > 0) || (at === tabs.length - 1 && d < 0)) d *= 0.3;
setDx(d);
}
function onUp() {
if (!drag.current.on) return;
drag.current.on = false;
const d = drag.current.lastX - drag.current.x0;
if (d < -44 || drag.current.v < -0.45) go(at + 1);
else if (d > 44 || drag.current.v > 0.45) go(at - 1);
else setDx(0);
}
function onKey(e: KeyEvt) {
const k = e.key === "ArrowRight" ? at + 1
: e.key === "ArrowLeft" ? at - 1
: e.key === "Home" ? 0
: e.key === "End" ? tabs.length - 1
: at;
if (k !== at) { go(k); e.preventDefault(); }
}
useEffect(() => {
const el = viewRef.current;
if (!el) return;
function wheel(e: WheelEvent) { e.preventDefault(); }
el.addEventListener("wheel", wheel, { passive: false });
return () => el.removeEventListener("wheel", wheel);
}, []);
return (
<div className={styles.wt} style={vars}>
<div className={styles.tabs} role="tablist" aria-label="Stats period" onKeyDown={onKey}>
<span className={styles.pill} style={{ transform: `translateX(${at * 72}px)` }} aria-hidden="true" />
{tabs.map((t, i) => (
<button
key={t}
type="button"
role="tab"
className={i === at ? styles.tabOn : styles.tab}
aria-selected={i === at}
aria-controls={`wt-p${i}`}
tabIndex={i === at ? 0 : -1}
onClick={(): void => go(i)}
>
{t}
</button>
))}
</div>
<div
ref={viewRef}
className={styles.view}
onPointerDown={onDown}
onPointerMove={onMove}
onPointerUp={onUp}
onPointerCancel={onUp}
>
<div
className={styles.track}
style={{ transform: `translateX(calc(${-at * 100}% + ${dx}px))` }}
>
{tabs.map((t, i) => (
<section key={t} role="tabpanel" id={`wt-p${i}`} className={styles.panel}>
<strong className={styles.num}>{[32000, 128000, 1480000][i].toLocaleString("en-US")}</strong>
<span className={styles.cap}>{`Visits ${t.toLowerCase()}`}</span>
</section>
))}
</div>
</div>
</div>
);
}04 스와이프 탭 전환 — 측정 누적면적 25.581% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 모바일 터치 제스처 9종 — 손가락 하나로 돌아가는 UI
이 데모가 속한 글
모바일 터치 제스처 9종 — 손가락 하나로 돌아가는 UI모바일 터치 제스처는 손가락이 화면에 닿아 움직인 거리와 속도를 읽어 화면을 바꾸는 조작 방식입니다. 아홉 개 중 둘은 손을 떼는 순간에야 결과가 갈리고, 하나는 끝까지 밀지 않으면 아무 일도 일어나지 않습니다. 이 글의 데모 전부 보기

