요금제 디자인 인터랙션 9종 — 토글부터 결제 버튼까지
요금제 디자인에서 실제로 일하는 건 카드 모양이 아니라 그 위에서 벌어지는 인터랙션입니다. 아홉 개 전부 라이브러리 없이 바닐라 JS와 SCSS로만 돌아가고, 첫 번째는 결제 주기를 바꿀 때 숫자가 진짜 바뀌는 그 한 동작만 담았습니다.
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 월·연 전환에 숫자가 굴러간다
- 02 통화를 바꾸면 기호와 금액이 같이 바뀐다
- 03 쓰는 만큼 가격이 다시 계산된다
- 04 추천 플랜만 커지고 이웃은 물러난다
- 05 커서를 따라 기울어지는 플랜 카드
- 06 포함 기능이 한 줄씩 켜진다
- 07 비교표가 묶음째 접히고 펼쳐진다
- 08 손가락으로 넘기는 플랜 카드
- 09 결제 버튼이 기다렸다가 결과를 보여준다
순서는 인기순이 아니라 사람이 요금표에서 값을 정해 가는 순서입니다. 먼저 결제 주기를 고르고(01), 자기 나라 통화로 바꿔 보고(02), 쓸 만큼을 넣어 요금을 계산합니다(03). 값이 정해지면 고르는 단계입니다. 추천 플랜이 앞으로 나오고(04), 고른 카드가 손을 따라 기울고(05), 무엇이 들어 있는지 한 줄씩 확인합니다(06). 마지막 셋은 확신이 서기 직전입니다. 표를 펼쳐 항목끼리 대조하고(07), 좁은 화면에서는 카드를 옆으로 넘기고(08), 결제 버튼을 눌러 답을 기다립니다(09). 아홉 개가 공유하는 규칙은 하나입니다. 상태를 담는 곳은 언제나 aria-checked·aria-selected·data-* 같은 속성이고, CSS는 그 속성만 읽습니다. 자바스크립트가 클래스와 스타일을 직접 손대기 시작하면 화면과 보조기술이 읽는 상태가 곧 갈라지기 때문입니다.
01월·연 전환에 숫자가 굴러간다
스위치를 누르면 월 29달러가 연간 결제 기준 23달러로 바뀌는데, 값이 툭 갈아 끼워지지 않고 한 자리씩 굴러 내려갑니다. 등록하지 않은 커스텀 속성은 애니메이션이 보간하지 못하므로, @property로 --n을 정수로 등록하고 그 값을 카운터로 그립니다. 24프레임 렌더에서 움직인 프레임은 19장, 강도는 110.4입니다.
@property --n {
syntax: "<integer>";
initial-value: 29;
inherits: false;
}
.bt__num {
--n: 29;
counter-reset: n var(--n);
font-size: 40px;
line-height: 46px;
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.bt__num::after { content: counter(n); }
02통화를 바꾸면 기호와 금액이 같이 바뀐다
목록에서 통화를 고르면 트리거의 코드와 기호, 왼쪽 금액이 한 번에 갈립니다. 금액 네 장은 같은 자리에 겹쳐 두고 뿌리의 data-cur 하나가 어느 장을 보일지 정합니다. 고른 뒤 초점을 트리거로 되돌릴 때는 preventScroll을 반드시 붙입니다.
function pick(li) {
root.classList.remove('is-demo');
root.setAttribute('data-cur', li.getAttribute('data-cur'));
opts.forEach(function (o) { o.setAttribute('aria-selected', String(o === li)); });
code.textContent = li.getAttribute('data-code');
tsym.textContent = li.getAttribute('data-sym');
open(false);
trig.focus({ preventScroll: true });
}
03쓰는 만큼 가격이 다시 계산된다
손잡이를 끌면 좌석 수가 3·5·10·20·50으로 바뀌고 요금이 좌석당 8달러로 다시 계산됩니다. 눈에 보이는 건 숫자지만 스크린리더에 전달되는 건 aria-valuetext의 문장이라, 읽히는 값이 "3"이 아니라 "3 좌석"이 됩니다. 누적면적 5.301%에 움직인 프레임 21장으로, 채움 막대와 손잡이가 쉬지 않고 미끄러지는 덕에 빈 프레임이 거의 없습니다.
var SEATS = [3, 5, 10, 20, 50];
function apply(v) {
var seats = SEATS[v - 1];
root.style.setProperty('--p', String(v));
seatn.textContent = String(seats);
fee.textContent = '$' + (seats * 8);
range.setAttribute('aria-valuetext', seats + ' ' + '좌석');
}
range.addEventListener('input', function () {
root.classList.remove('is-demo');
apply(parseInt(range.value, 10));
});
04추천 플랜만 커지고 이웃은 물러난다
어느 카드든 마우스를 올리면 그 카드가 1.06배로 나오고 나머지 둘은 0.94배로 물러납니다. 물러나는 규칙이 :has() 덕에 특이도가 더 높아서, 앞으로 나오는 규칙에도 같은 :has()를 붙여야 이깁니다. 리본은 카드 위로 16px 삐져나오는 절대배치라 뿌리에서 그보다 넉넉히 자리를 미리 비워 두었습니다.
.pr__row:has(.pr__card:hover) .pr__card,
.pr__row:has(.pr__card:focus-within) .pr__card { transform: scale(.94); }
.pr__row:has(.pr__card:hover) .pr__card:hover,
.pr__row:has(.pr__card:focus-within) .pr__card:focus-within { transform: scale(1.06); }
.pr.is-demo .pr__card {
animation-name: pr-lift;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.pr.is-demo .pr__card[data-plan="starter"] { animation-delay: 0ms; }
.pr.is-demo .pr__card[data-plan="pro"] { animation-delay: 300ms; }
.pr.is-demo .pr__card[data-plan="business"] { animation-delay: 600ms; }
05커서를 따라 기울어지는 플랜 카드
커서의 카드 안 상대 좌표를 0에서 1 사이로 읽어 두 축의 각도로 바꾸고, 같은 좌표를 글레어의 중심으로도 씁니다. 기울기를 9도로 묶어 둔 이유는 각도가 커질수록 카드가 모서리에서 본 그림에 가까워지기 때문입니다. 커서를 따라가는 동안에는 transition이 없고 떠날 때만 붙어, 손을 뗀 뒤에야 스르르 눕습니다.
var LIMIT = 9; // deg — 이보다 크게 기울이면 카드가 모서리에서 본 그림이 된다
function tilt(e) {
root.classList.remove('is-demo');
card.classList.remove('is-idle');
var r = card.getBoundingClientRect();
var x = (e.clientX - r.left) / r.width;
var y = (e.clientY - r.top) / r.height;
card.style.setProperty('--ry', ((x - 0.5) * 2 * LIMIT).toFixed(2) + 'deg');
card.style.setProperty('--rx', ((0.5 - y) * 2 * LIMIT).toFixed(2) + 'deg');
card.style.setProperty('--mx', (x * 100).toFixed(1) + '%');
card.style.setProperty('--my', (y * 100).toFixed(1) + '%');
}
scene.addEventListener('pointermove', tilt);
06포함 기능이 한 줄씩 켜진다
다섯 줄이 맨 위부터 160밀리초씩 늦게 켜집니다. 여기서 animation: 단축을 쓰면 그 한 줄이 animation-delay를 0으로 되돌려 다섯 줄이 동시에 움직이므로 낱개 속성으로만 씁니다. 지연 구간에는 애니메이션이 적용되지 않아 첫 프레임에 완성된 줄이 번쩍이는데, animation-fill-mode: backwards가 그 구간을 0% 키프레임으로 덮어 막습니다.
.fc.is-demo .fc__row {
animation-name: fc-line;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-delay: calc(var(--i) * #{$stagger});
animation-fill-mode: backwards;
}
@keyframes fc-line {
0% { opacity: .26; transform: translateX(-14px); }
12% { opacity: 1; transform: translateX(0); }
74% { opacity: 1; transform: translateX(0); }
86% { opacity: .26; transform: translateX(-14px); }
100% { opacity: .26; transform: translateX(-14px); }
}
07비교표가 묶음째 접히고 펼쳐진다
묶음 제목을 누르면 그 아래 행들이 접히는데, 높이를 재는 코드가 한 줄도 없습니다. 칸 안쪽 상자가 한 줄짜리 그리드이고 그 트랙이 1fr에서 0fr로 보간될 뿐입니다. 표는 <th scope="col">과 <th scope="row">를 가진 진짜 표로 남아, 접혀 있어도 행과 열의 머리글 관계가 그대로입니다.
.ca__box {
display: grid;
grid-template-rows: 1fr;
overflow: hidden;
transition: grid-template-rows $duration $easing;
}
.ca__group[data-open="0"] .ca__box { grid-template-rows: 0fr; }
.ca__group[data-open="0"] .ca__caret { transform: rotate(-90deg); }
08손가락으로 넘기는 플랜 카드
카드는 세 장뿐이고 복제본도 되감기도 없습니다. 끌고 있는 동안에는 트랙의 transition을 떼고 --dx만 갱신하다가, 손을 떼면 이동 거리를 카드 한 칸 간격(카드 폭에 12px을 더한 값)으로 나눠 반올림해 가장 가까운 카드로 붙입니다. 스크롤 상자와 트랙을 반드시 다른 요소로 두는데, 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; }
09결제 버튼이 기다렸다가 결과를 보여준다
세 얼굴이 같은 자리에 겹쳐 있고 data-state 하나가 어느 얼굴을 보일지 정합니다. 기다리는 동안 버튼은 aria-busy="true"이면서 disabled라 두 번 눌리지 않고, 안 보이는 얼굴은 aria-hidden이라 라이브 영역이 새 문장 하나만 읽습니다. 로딩 1.2초, 성공 표시 1.2초 뒤 기본 상태로 돌아옵니다.
function state(name) {
root.classList.remove('is-demo');
root.setAttribute('data-state', name);
btn.setAttribute('aria-busy', String(name === 'busy'));
btn.disabled = name !== 'idle';
hidden.forEach(function (el) {
if (el.getAttribute('data-face') === name) { el.removeAttribute('aria-hidden'); } else { el.setAttribute('aria-hidden', 'true'); }
});
}
btn.addEventListener('click', function () {
timers.forEach(clearTimeout);
timers = [];
state('busy');
timers.push(setTimeout(function () { state('done'); }, 1200));
timers.push(setTimeout(function () { state('idle'); }, 2400));
});
어디서 깨지는가 — 미끄러지는 값과 끊어지는 글자가 어긋난다
이 편에서 제일 오래 붙잡은 건 코드가 아니라 대표 이미지였습니다. 이 블로그의 대표 이미지는 2초를 24장으로 나눈 프레임 가운데 앞 장과 가장 크게 달라진 한 장으로 뽑히는데, 03번의 첫 판이 그 규칙에 정확히 걸렸습니다. 손잡이는 --p를 따라 눈금과 눈금 사이를 부드럽게 미끄러지는데 좌석 수와 요금은 steps(1, end)로 끊어 바뀌니, 값이 도착하는 시각에 컷을 두면 미끄러지는 네 프레임 내내 숫자만 뒤처집니다. 실제로 뽑힌 첫 장은 손잡이가 다섯 번째 눈금 코앞인데 글자는 20 좌석·160달러인 장면이었습니다. 고친 방법은 컷을 도착 시각이 아니라 눈금과 눈금의 한가운데로 옮긴 것입니다. step 슬라이더가 실제로 값을 바꾸는 지점이 그 중간이기 때문이고, 그렇게 하니 어긋나는 폭이 절반으로 줄어 대표 이미지가 두 번째 눈금 위에 5 좌석·40달러로 맞아떨어졌습니다. 같은 병이 08번의 점 표시에도 있었습니다. 카드가 도착하기 6% 전에 점이 먼저 바뀌어서, 카드는 아직 1번인데 점은 2번을 가리키는 장면이 대표 이미지였습니다. 점의 컷을 카드 도착 시각과 같은 36%·64%로 맞추는 한 줄이 답이었습니다. 06번은 반대쪽 함정이었습니다. 다섯 줄을 부모에서 한꺼번에 투명하게 지워 다음 바퀴를 시작했더니 그 한 프레임이 압도적으로 크게 변한 장이 되어, 대표 이미지가 거의 빈 카드로 뽑혔습니다. 꺼진 줄도 26% 밝기로 남기고 끄는 일까지 줄마다 제 시차에 맡기자 지금 값은 누적면적 3.764%에 움직인 프레임 17장이고, 어느 한 프레임도 혼자 화면을 차지하지 않습니다. 마지막 하나는 07번이었는데, 접히는 칸에 세로 패딩을 주면 트랙이 0fr이 되어도 그 패딩만큼 12px이 남아 완전히 닫히지 않습니다. 칸 높이는 line-height로만 만들고 상자 안의 줄에 min-height: 0을 두어야 바닥까지 닫힙니다. 네 가지 모두 코드를 읽어서는 안 보이고 프레임을 한 장씩 열어 봐야 보이는 종류라, 아홉 개의 대표 이미지를 전부 눈으로 열어 확인한 뒤에야 넘어갔습니다. 이 아홉 개를 담은 zip의 압축 비밀번호는 whhp3vgx 이고, 안에는 바닐라 아홉 벌과 React 포트 아홉 벌이 같은 이름으로 들어 있습니다.
접근성 — reduced-motion 에서 무엇이 남는가
아홉 개 모두 prefers-reduced-motion: reduce에서 자동 루프와 장식 애니메이션을 끄고 상태 값은 남깁니다. 01번은 스위치가 미끄러지는 대신 바로 자리를 잡고 aria-checked는 그대로 뒤집히며, 07번은 grid-template-rows 보간 없이 즉시 접히고 펼쳐집니다. 04번과 05번은 transition을 떼어 크기와 각도가 중간 단계 없이 곧바로 바뀝니다. 값을 읽어 주는 쪽은 움직임과 무관하게 살아 있습니다. 01번과 03번의 가격 칸은 aria-live="polite", 03번의 슬라이더는 aria-valuetext, 09번의 버튼은 aria-busy와 안내문의 aria-live가 상태를 말합니다. 07번의 표는 열 머리글에 scope="col", 행 머리글에 scope="row"를 두어 접힌 상태에서도 관계가 남습니다.
글자 대비는 WCAG 상대휘도 공식으로 직접 계산했습니다. 반투명 카드 배경은 무대 색과 합성한 실효색으로 쟀습니다.
| 쓰이는 곳 | 글자 | 바탕 | 대비 |
|---|---|---|---|
| 01 가격 숫자 · 파랑 / 흰 카드 | #2f6df6 |
#ffffff |
4.53:1 |
| 01·03·04·07 본문 잉크 / 흰 카드 | #17141a |
#ffffff |
18.24:1 |
| 02 금액 · 민트 / 먹 카드 | #4cd4a6 |
#252228 |
8.43:1 |
| 04 추천 카드 글자 · 순백 / 파랑 | #ffffff |
#2f6df6 |
4.53:1 |
| 05 플랜 이름 · 라일락 / 먹 카드 | #b9a0ff |
#2a272c |
6.69:1 |
| 06 기능 줄 · 크림 / 눌러 둔 주황 카드 | #fff7e6 |
#b83716 |
5.48:1 |
| 08 가격 · 하늘색 / 먹 카드 | #6ec8ff |
#27242a |
8.29:1 |
| 09 성공 글자 · 민트 / 먹 버튼 | #4cd4a6 |
#17141a |
9.79:1 |
두 곳은 처음 고른 값이 기준을 못 넘어 바꿨습니다. 04번 추천 카드의 크림 글자는 파랑 위에서 4.25:1이라 순백으로 올려 4.53:1을 만들었고, 같은 카드의 단위 글자는 72% 투명도에서 3.10:1로 떨어져 투명도를 걷어냈습니다. 06번은 주황 무대 위 카드를 검정 16%로만 눌렀더니 4.25:1이어서 28%까지 눌러 5.48:1을 확보했습니다. 요금표는 사람이 돈을 쓰는 화면이라, 여기서 대비를 양보하면 잃는 것이 분명합니다. 이웃한 가격표 CSS 애니메이션 모음은 같은 소재를 CSS만으로 다루고, 가격표 분류에는 이 기둥의 다른 편들이 모여 있습니다. @property 등록 규격은 MDN @property 문서를 따랐습니다.
자주 묻는 질문
@property 없이 가격 숫자를 굴릴 수 있나요
커스텀 속성은 등록하지 않으면 브라우저가 그냥 글자로 봐서 29와 23 사이에 중간값이 없습니다. @property로 syntax: "<integer>"를 선언해야 그 사이가 채워집니다. 등록을 쓸 수 없는 환경이라면 자바스크립트로 requestAnimationFrame 안에서 숫자를 직접 써 넣는 방법밖에 없는데, 그러면 프레임마다 텍스트 노드를 갈아 끼우게 됩니다.
월·연 토글에 체크박스 대신 role="switch"를 쓴 이유가 뭔가요
결제 주기는 켜고 끄는 값이 아니라 둘 중 하나를 고르는 값이라 라디오도 후보입니다. 다만 선택지가 정확히 둘이고 화면에 알약 하나만 두는 형태라면 스위치가 읽히는 문장이 짧습니다. 셋 이상이 되면 그때는 라디오 그룹으로 바꾸는 편이 낫습니다.
무한 캐러셀처럼 끝에서 처음으로 되감고 싶으면 어떻게 하나요
08번은 일부러 되감지 않습니다. 되감으려면 첫 카드의 복제본을 끝에 붙여 두었다가 몰래 자리를 바꾸는 방식이 흔한데, 그 순간 스크린리더에는 같은 카드가 두 번 읽히고 키보드 초점도 복제본에 갇힙니다. 플랜이 셋뿐인 요금표에서는 양 끝에서 멈추는 편이 잃는 게 적습니다.