08Plan cards you swipe with a thumb
On a narrow screen the cards track your finger as you drag and settle onto the nearest card when you let go; no copy of the first card is used to loop back, so nothing teleports.
Best forMobile pricing screens where three plans will not fit side by side
setPointerCapturetranslateXaria-current

<main class="stage">
<div class="sw is-demo" role="group" aria-label="Swipe between plans">
<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">Starter</span></p>
<p class="sw__price"><span class="sw__amt">$9</span><span class="sw__per">/ mo</span></p>
<ul class="sw__list">
<li>Unlimited projects included</li>
<li>Invite teammates</li>
</ul>
</div>
</article>
<article class="sw__card">
<div class="sw__inner">
<p class="sw__name"><span class="sw__nametext">Pro</span><span class="sw__tag">Popular</span></p>
<p class="sw__price"><span class="sw__amt">$29</span><span class="sw__per">/ mo</span></p>
<ul class="sw__list">
<li>Priority support</li>
<li>Invite teammates</li>
</ul>
</div>
</article>
<article class="sw__card">
<div class="sw__inner">
<p class="sw__name"><span class="sw__nametext">Business</span></p>
<p class="sw__price"><span class="sw__amt">$79</span><span class="sw__per">/ mo</span></p>
<ul class="sw__list">
<li>Audit log</li>
<li>Dedicated support</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">Starter</span></button>
<button class="sw__dot" type="button" data-go="1"><span class="sw__vh">Pro</span></button>
<button class="sw__dot" type="button" data-go="2"><span class="sw__vh">Business</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 Plan cards you swipe with a thumb — Measured area 20.361% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · Pricing Table Design: 9 Live Interactions
The post this demo lives in
Pricing Table Design: 9 Live InteractionsIn pricing table design, the work happens on top of the cards, not inside them. All nine run on vanilla JS and SCSS, and See all demos in this post
If you like this effect - similar demos
Landing sections01 Plan cards — a card row that recomputes its price on every billing switch9 Pricing Page Design Sections (Copy-Paste)
Landing sections04 Sticky bottom app barWebsite Footer Design: 9 Copy-Paste Patterns
Landing sections03 Big quote with avatar switcherTestimonial Section Design: 9 Social Proof Blocks