08指でめくるプランカード
狭い画面でカードを横に引くと指に追従し、離した瞬間に近いカードへ吸い付きます。先頭カードを複製して端で巻き戻す方式は使わないので瞬間移動がありません。
使いどころ三つのプランが横に並ばないモバイルの料金画面
setPointerCapturetranslateXaria-current

<main class="stage">
<div class="sw is-demo" role="group" aria-label="プランを横に送る">
<div class="sw__view" id="sw-view">
<div class="sw__track" id="sw-track">
<article class="sw__card">
<div class="sw__inner">
<p class="sw__name"><span class="sw__nametext">スターター</span></p>
<p class="sw__price"><span class="sw__amt">$9</span><span class="sw__per">/ 月</span></p>
<ul class="sw__list">
<li>プロジェクトは無制限</li>
<li>メンバーを招待</li>
</ul>
</div>
</article>
<article class="sw__card">
<div class="sw__inner">
<p class="sw__name"><span class="sw__nametext">プロ</span><span class="sw__tag">おすすめ</span></p>
<p class="sw__price"><span class="sw__amt">$29</span><span class="sw__per">/ 月</span></p>
<ul class="sw__list">
<li>優先サポート</li>
<li>メンバーを招待</li>
</ul>
</div>
</article>
<article class="sw__card">
<div class="sw__inner">
<p class="sw__name"><span class="sw__nametext">ビジネス</span></p>
<p class="sw__price"><span class="sw__amt">$79</span><span class="sw__per">/ 月</span></p>
<ul class="sw__list">
<li>監査ログ</li>
<li>専任サポート</li>
</ul>
</div>
</article>
</div>
</div>
<div class="sw__dots">
<button class="sw__dot" type="button" data-go="0" aria-current="true"><span class="sw__vh">スターター</span></button>
<button class="sw__dot" type="button" data-go="1"><span class="sw__vh">プロ</span></button>
<button class="sw__dot" type="button" data-go="2"><span class="sw__vh">ビジネス</span></button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-sky;
$gapx: $sp-3; // 카드 사이 간격 — 무한 루프 트랙이 아니어도 flex gap 대신 margin-right 로 준다 (BD15)
@include stage($stage-ink);
.sw {
box-sizing: border-box;
width: min(300px, 82vw);
display: flex;
flex-direction: column;
gap: $sp-3;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-cream;
}
// 뷰포트 상자와 트랙은 반드시 다른 요소다. transform 은 트랙에만 건다 —
// 뷰포트는 overflow: hidden 으로 잘라 내는 창이다. 창째 밀면 내용이 창과 함께 움직여 스크롤이 아예 일어나지 않는다
.sw__view {
overflow: hidden;
width: 100%;
border-radius: $r-card;
touch-action: pan-y;
cursor: grab;
}
.sw__track {
--i: 0;
--dx: 0px;
--base: calc(-1 * var(--i) * (100% + #{$gapx}));
display: flex;
transform: translateX(calc(var(--base) + var(--dx)));
transition: transform $duration $easing;
}
.sw__track.is-drag { transition: none; }
.sw__card {
box-sizing: border-box;
flex: 0 0 auto;
width: 100%;
margin-right: $gapx;
}
.sw__card:last-child { margin-right: 0; }
.sw__inner {
box-sizing: border-box;
height: 100%;
padding: $sp-4;
border: 2px solid rgba(255, 255, 255, .14);
border-radius: $r-card;
background-color: rgba(255, 255, 255, .07);
}
.sw__name {
display: flex;
align-items: center;
gap: $sp-2;
margin: 0;
font-size: 13px;
line-height: 20px;
font-weight: 700;
color: $subject-cream;
}
.sw__nametext {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sw__tag {
flex: 0 0 auto;
padding-left: $sp-2;
padding-right: $sp-2;
border-radius: $r-pill;
background-color: $color;
color: $subject-ink;
font-size: 10px;
line-height: 18px;
font-weight: 700;
}
.sw__price {
display: flex;
align-items: baseline;
gap: $sp-1;
margin-top: $sp-2;
margin-bottom: $sp-2;
margin-left: 0;
margin-right: 0;
}
.sw__amt {
font-size: 26px;
line-height: 32px;
font-weight: 800;
color: $color;
}
.sw__per {
font-size: 12px;
line-height: 18px;
font-weight: 500;
color: rgba(255, 255, 255, .62);
}
.sw__list {
margin: 0;
padding: 0;
list-style: none;
}
.sw__list li {
position: relative;
padding-left: $sp-4;
font-size: 12px;
line-height: 20px;
font-weight: 500;
color: $subject-cream;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sw__list li + li { margin-top: $sp-1; }
.sw__list li::before {
content: "";
position: absolute;
left: 0;
top: 7px;
width: 6px;
height: 6px;
border-radius: 50%;
background-color: $color;
}
// ── 점 ──
.sw__dots {
display: flex;
justify-content: center;
gap: $sp-2;
}
.sw__dot {
position: relative;
box-sizing: border-box;
width: 8px;
height: 8px;
margin: 0;
padding: 0;
border: 0;
border-radius: $r-pill;
background-color: rgba(255, 255, 255, .28);
cursor: pointer;
transition: width $duration $easing, background-color $duration $easing;
}
.sw__dot[aria-current="true"] {
width: 22px;
background-color: $color;
}
.sw__dot:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.sw__vh {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
white-space: nowrap;
clip-path: inset(50%);
}
// ── 자동 루프: 1→2→3 으로 밀고, 3→1 은 카드를 복제하지 않고 그대로 미끄러져 돌아온다 ──
.sw.is-demo .sw__track {
animation-name: sw-slide;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes sw-slide {
0% { transform: translateX(0); }
22% { transform: translateX(0); }
36% { transform: translateX(calc(-1 * (100% + #{$gapx}))); }
50% { transform: translateX(calc(-1 * (100% + #{$gapx}))); }
64% { transform: translateX(calc(-2 * (100% + #{$gapx}))); }
74% { transform: translateX(calc(-2 * (100% + #{$gapx}))); }
88% { transform: translateX(0); }
100% { transform: translateX(0); }
}
// 카드가 넘어갈 때마다 살짝 눌렸다 펴진다 — 컷만으로는 채워지지 않는 프레임을 메운다
.sw.is-demo .sw__inner {
animation-name: sw-lift;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes sw-lift {
0% { transform: scale(1); }
22% { transform: scale(1); }
30% { transform: scale(.95); }
43% { transform: scale(1); }
50% { transform: scale(1); }
58% { transform: scale(.95); }
70% { transform: scale(1); }
74% { transform: scale(1); }
81% { transform: scale(.95); }
90% { transform: scale(1); }
100% { transform: scale(1); }
}
// 점의 활성 표시는 컷 — 반투명하게 섞이면 유령 프레임이 남는다
.sw.is-demo .sw__dot {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.sw.is-demo .sw__dot[data-go="0"] { animation-name: sw-dot-a; }
.sw.is-demo .sw__dot[data-go="1"] { animation-name: sw-dot-b; }
.sw.is-demo .sw__dot[data-go="2"] { animation-name: sw-dot-c; }
@keyframes sw-dot-a {
0% { width: 22px; background-color: $subject-sky; }
36% { width: 8px; background-color: rgba(255, 255, 255, .28); }
88% { width: 22px; background-color: $subject-sky; }
100% { width: 22px; background-color: $subject-sky; }
}
@keyframes sw-dot-b {
0% { width: 8px; background-color: rgba(255, 255, 255, .28); }
36% { width: 22px; background-color: $subject-sky; }
64% { width: 8px; background-color: rgba(255, 255, 255, .28); }
100% { width: 8px; background-color: rgba(255, 255, 255, .28); }
}
@keyframes sw-dot-c {
0% { width: 8px; background-color: rgba(255, 255, 255, .28); }
64% { width: 22px; background-color: $subject-sky; }
88% { width: 8px; background-color: rgba(255, 255, 255, .28); }
100% { width: 8px; background-color: rgba(255, 255, 255, .28); }
}
@media (max-width: 340px) {
.sw { gap: $sp-2; }
.sw__inner { padding: $sp-2; }
.sw__name { gap: $sp-1; font-size: 12px; line-height: 18px; }
.sw__tag { font-size: 9px; line-height: 16px; }
.sw__price { margin-top: $sp-1; margin-bottom: $sp-1; }
.sw__amt { font-size: 22px; line-height: 26px; }
.sw__per { font-size: 11px; line-height: 16px; }
.sw__list li { padding-left: $sp-3; font-size: 11px; line-height: 16px; }
.sw__list li::before { top: 5px; }
}
@media (prefers-reduced-motion: reduce) {
.sw.is-demo .sw__track,
.sw.is-demo .sw__inner,
.sw.is-demo .sw__dot { animation: none !important; }
.sw__track,
.sw__dot { transition: none; }
}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);
}
.sw {
box-sizing: border-box;
width: min(300px, 82vw);
display: flex;
flex-direction: column;
gap: 12px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #fff7e6;
}
.sw__view {
overflow: hidden;
width: 100%;
border-radius: 16px;
touch-action: pan-y;
cursor: grab;
}
.sw__track {
--i: 0;
--dx: 0px;
--base: calc(-1 * var(--i) * (100% + 12px));
display: flex;
transform: translateX(calc(var(--base) + var(--dx)));
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.sw__track.is-drag {
transition: none;
}
.sw__card {
box-sizing: border-box;
flex: 0 0 auto;
width: 100%;
margin-right: 12px;
}
.sw__card:last-child {
margin-right: 0;
}
.sw__inner {
box-sizing: border-box;
height: 100%;
padding: 16px;
border: 2px solid rgba(255, 255, 255, 0.14);
border-radius: 16px;
background-color: rgba(255, 255, 255, 0.07);
}
.sw__name {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
font-size: 13px;
line-height: 20px;
font-weight: 700;
color: #fff7e6;
}
.sw__nametext {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sw__tag {
flex: 0 0 auto;
padding-left: 8px;
padding-right: 8px;
border-radius: 999px;
background-color: #6ec8ff;
color: #17141a;
font-size: 10px;
line-height: 18px;
font-weight: 700;
}
.sw__price {
display: flex;
align-items: baseline;
gap: 4px;
margin-top: 8px;
margin-bottom: 8px;
margin-left: 0;
margin-right: 0;
}
.sw__amt {
font-size: 26px;
line-height: 32px;
font-weight: 800;
color: #6ec8ff;
}
.sw__per {
font-size: 12px;
line-height: 18px;
font-weight: 500;
color: rgba(255, 255, 255, 0.62);
}
.sw__list {
margin: 0;
padding: 0;
list-style: none;
}
.sw__list li {
position: relative;
padding-left: 16px;
font-size: 12px;
line-height: 20px;
font-weight: 500;
color: #fff7e6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sw__list li + li {
margin-top: 4px;
}
.sw__list li::before {
content: "";
position: absolute;
left: 0;
top: 7px;
width: 6px;
height: 6px;
border-radius: 50%;
background-color: #6ec8ff;
}
.sw__dots {
display: flex;
justify-content: center;
gap: 8px;
}
.sw__dot {
position: relative;
box-sizing: border-box;
width: 8px;
height: 8px;
margin: 0;
padding: 0;
border: 0;
border-radius: 999px;
background-color: rgba(255, 255, 255, 0.28);
cursor: pointer;
transition: width 300ms cubic-bezier(0.2, 0.8, 0.2, 1), background-color 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.sw__dot[aria-current=true] {
width: 22px;
background-color: #6ec8ff;
}
.sw__dot:focus-visible {
outline: 2px solid #6ec8ff;
outline-offset: 2px;
}
.sw__vh {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
white-space: nowrap;
clip-path: inset(50%);
}
.sw.is-demo .sw__track {
animation-name: sw-slide;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes sw-slide {
0% {
transform: translateX(0);
}
22% {
transform: translateX(0);
}
36% {
transform: translateX(calc(-1 * (100% + 12px)));
}
50% {
transform: translateX(calc(-1 * (100% + 12px)));
}
64% {
transform: translateX(calc(-2 * (100% + 12px)));
}
74% {
transform: translateX(calc(-2 * (100% + 12px)));
}
88% {
transform: translateX(0);
}
100% {
transform: translateX(0);
}
}
.sw.is-demo .sw__inner {
animation-name: sw-lift;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes sw-lift {
0% {
transform: scale(1);
}
22% {
transform: scale(1);
}
30% {
transform: scale(0.95);
}
43% {
transform: scale(1);
}
50% {
transform: scale(1);
}
58% {
transform: scale(0.95);
}
70% {
transform: scale(1);
}
74% {
transform: scale(1);
}
81% {
transform: scale(0.95);
}
90% {
transform: scale(1);
}
100% {
transform: scale(1);
}
}
.sw.is-demo .sw__dot {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.sw.is-demo .sw__dot[data-go="0"] {
animation-name: sw-dot-a;
}
.sw.is-demo .sw__dot[data-go="1"] {
animation-name: sw-dot-b;
}
.sw.is-demo .sw__dot[data-go="2"] {
animation-name: sw-dot-c;
}
@keyframes sw-dot-a {
0% {
width: 22px;
background-color: #6ec8ff;
}
36% {
width: 8px;
background-color: rgba(255, 255, 255, 0.28);
}
88% {
width: 22px;
background-color: #6ec8ff;
}
100% {
width: 22px;
background-color: #6ec8ff;
}
}
@keyframes sw-dot-b {
0% {
width: 8px;
background-color: rgba(255, 255, 255, 0.28);
}
36% {
width: 22px;
background-color: #6ec8ff;
}
64% {
width: 8px;
background-color: rgba(255, 255, 255, 0.28);
}
100% {
width: 8px;
background-color: rgba(255, 255, 255, 0.28);
}
}
@keyframes sw-dot-c {
0% {
width: 8px;
background-color: rgba(255, 255, 255, 0.28);
}
64% {
width: 22px;
background-color: #6ec8ff;
}
88% {
width: 8px;
background-color: rgba(255, 255, 255, 0.28);
}
100% {
width: 8px;
background-color: rgba(255, 255, 255, 0.28);
}
}
@media (max-width: 340px) {
.sw {
gap: 8px;
}
.sw__inner {
padding: 8px;
}
.sw__name {
gap: 4px;
font-size: 12px;
line-height: 18px;
}
.sw__tag {
font-size: 9px;
line-height: 16px;
}
.sw__price {
margin-top: 4px;
margin-bottom: 4px;
}
.sw__amt {
font-size: 22px;
line-height: 26px;
}
.sw__per {
font-size: 11px;
line-height: 16px;
}
.sw__list li {
padding-left: 12px;
font-size: 11px;
line-height: 16px;
}
.sw__list li::before {
top: 5px;
}
}
@media (prefers-reduced-motion: reduce) {
.sw.is-demo .sw__track,
.sw.is-demo .sw__inner,
.sw.is-demo .sw__dot {
animation: none !important;
}
.sw__track,
.sw__dot {
transition: none;
}
}// 실제 동작: 카드는 3장뿐이다. 복제도 텔레포트도 없고 양 끝에서 그냥 멈춘다.
// transform 은 언제나 트랙에만 건다 — 잘라 내는 창인 뷰포트를 밀면 내용이 창과 함께 움직여 스크롤이 안 된다.
var root = document.querySelector('.sw');
var view = document.getElementById('sw-view');
var track = document.getElementById('sw-track');
var dots = [].slice.call(document.querySelectorAll('.sw__dot'));
var last = dots.length - 1;
var index = 0, startX = 0, dragging = false;
function step() { return view.getBoundingClientRect().width + 12; }
function go(k) {
index = Math.max(0, Math.min(last, k)); // 끝을 넘지 않게 clamp
track.style.setProperty('--i', String(index));
track.style.setProperty('--dx', '0px');
dots.forEach(function (d, j) {
if (j === index) { d.setAttribute('aria-current', 'true'); } else { d.removeAttribute('aria-current'); }
});
}
view.addEventListener('pointerdown', function (e) {
root.classList.remove('is-demo');
// 손가락이 상자 밖으로 나가도 move·up 이 계속 이 요소로 온다
e.currentTarget.setPointerCapture(e.pointerId);
dragging = true;
startX = e.clientX;
track.classList.add('is-drag');
});
view.addEventListener('pointermove', function (e) {
if (!dragging) { return; }
track.style.setProperty('--dx', (e.clientX - startX) + 'px');
});
function release(e) {
if (!dragging) { return; }
dragging = false;
track.classList.remove('is-drag');
go(index - Math.round((e.clientX - startX) / step())); // 가장 가까운 카드로 스냅
}
view.addEventListener('pointerup', release);
view.addEventListener('pointercancel', release);
dots.forEach(function (d, j) {
d.addEventListener('click', function () { root.classList.remove('is-demo'); go(j); });
});
go(0);import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SwipePlanCards.module.scss";
export type SwipePlan = { name: string; price: string; tag?: string; features: [string, string] };
export type SwipePlanCardsProps = {
plans?: SwipePlan[];
per?: string;
label?: string;
duration?: number;
easing?: string;
color?: string;
};
// React 이벤트 타입은 'react' 앰비언트에 없다 — 쓰는 모양만 여기서 직접 적는다
type PtrEvt = {
clientX: number;
pointerId: number;
currentTarget: { setPointerCapture(id: number): void; getBoundingClientRect(): { width: number } };
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type TrackVars = CSSProperties & { "--i"?: string; "--dx"?: string };
const GAP = 12; // 카드 사이 간격 — scss 의 margin-right 와 같은 값
const DEFAULT_PLANS: SwipePlan[] = [
{ name: "Starter", price: "$9", features: ["Unlimited projects included", "Invite teammates"] },
{ name: "Pro", price: "$29", tag: "Popular", features: ["Priority support", "Invite teammates"] },
{ name: "Business", price: "$79", features: ["Audit log", "Dedicated support"] },
];
export default function SwipePlanCards({
plans = DEFAULT_PLANS,
per = "/ mo",
label = "Swipe between plans",
duration = 300,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#6ec8ff",
}: SwipePlanCardsProps) {
const [demo, setDemo] = useState(true);
const [index, setIndex] = useState(0);
const [dx, setDx] = useState(0);
const [drag, setDrag] = useState(false);
const [grab, setGrab] = useState({ x: 0, w: 0 }); // 잡은 지점과 그때의 뷰포트 너비
const last = plans.length - 1;
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const trackVars: TrackVars = { "--i": String(index), "--dx": `${dx}px` };
// 카드는 plans.length 장뿐이다. 복제도 텔레포트도 없고 양 끝에서 clamp 으로 멈춘다
function go(k: number) {
setIndex(Math.max(0, Math.min(last, k)));
setDx(0);
}
function down(e: PtrEvt) {
setDemo(false);
// 손가락이 상자 밖으로 나가도 move·up 이 계속 이 요소로 온다
e.currentTarget.setPointerCapture(e.pointerId);
setGrab({ x: e.clientX, w: e.currentTarget.getBoundingClientRect().width });
setDrag(true);
}
function move(e: PtrEvt) {
if (!drag) return;
setDx(e.clientX - grab.x);
}
function release(e: PtrEvt) {
if (!drag) return;
setDrag(false);
go(index - Math.round((e.clientX - grab.x) / (grab.w + GAP))); // 가장 가까운 카드로 스냅
}
return (
<div className={`${styles.sw} ${demo ? styles.isDemo : ""}`} style={vars} role="group" aria-label={label}>
{/* 뷰포트 상자와 트랙은 다른 요소다. transform 은 트랙에만 — 잘라 내는 창인 뷰포트를 밀면 내용이 창과 함께 움직인다 */}
<div
className={styles.view}
onPointerDown={down}
onPointerMove={move}
onPointerUp={release}
onPointerCancel={release}
>
<div className={`${styles.track} ${drag ? styles.isDrag : ""}`} style={trackVars}>
{plans.map((p) => (
<article key={p.name} className={styles.card}>
<div className={styles.inner}>
<p className={styles.name}>
<span className={styles.nametext}>{p.name}</span>
{p.tag ? <span className={styles.tag}>{p.tag}</span> : null}
</p>
<p className={styles.price}>
<span className={styles.amt}>{p.price}</span>
<span className={styles.per}>{per}</span>
</p>
<ul className={styles.list}>
<li>{p.features[0]}</li>
<li>{p.features[1]}</li>
</ul>
</div>
</article>
))}
</div>
</div>
<div className={styles.dots}>
{plans.map((p, i) => (
<button
key={p.name}
className={styles.dot}
type="button"
data-go={String(i)}
aria-current={i === index ? "true" : undefined}
onClick={() => { setDemo(false); go(i); }}
>
<span className={styles.vh}>{p.name}</span>
</button>
))}
</div>
</div>
);
}08 指でめくるプランカード — 計測 累積面積 20.361% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · 料金表 デザイン 実装9選 — 切り替えも計算も動く
このデモが属する記事
料金表 デザイン 実装9選 — 切り替えも計算も動く料金表 デザインで実際に効くのはカードの形ではなく、その上で起きるインタラクションです。9 つはすべてライブラリなしの素の JS と SCSS だけで動き、最初の 1 つは支払い周期を切り替えたときに数字が実際に変わる、その動作だけを入れま この記事のデモをすべて見る

