GODRICH
#css애니메이션#버튼#로딩#전환효과#스크롤#대시보드#실전ui#디자인트렌드

요금제 디자인 인터랙션 9종 — 토글부터 결제 버튼까지

요금제 디자인에서 실제로 일하는 건 카드 모양이 아니라 그 위에서 벌어지는 인터랙션입니다. 아홉 개 전부 라이브러리 없이 바닐라 JS와 SCSS로만 돌아가고, 첫 번째는 결제 주기를 바꿀 때 숫자가 진짜 바뀌는 그 한 동작만 담았습니다.

자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip

순서는 인기순이 아니라 사람이 요금표에서 값을 정해 가는 순서입니다. 먼저 결제 주기를 고르고(01), 자기 나라 통화로 바꿔 보고(02), 쓸 만큼을 넣어 요금을 계산합니다(03). 값이 정해지면 고르는 단계입니다. 추천 플랜이 앞으로 나오고(04), 고른 카드가 손을 따라 기울고(05), 무엇이 들어 있는지 한 줄씩 확인합니다(06). 마지막 셋은 확신이 서기 직전입니다. 표를 펼쳐 항목끼리 대조하고(07), 좁은 화면에서는 카드를 옆으로 넘기고(08), 결제 버튼을 눌러 답을 기다립니다(09). 아홉 개가 공유하는 규칙은 하나입니다. 상태를 담는 곳은 언제나 aria-checked·aria-selected·data-* 같은 속성이고, CSS는 그 속성만 읽습니다. 자바스크립트가 클래스와 스타일을 직접 손대기 시작하면 화면과 보조기술이 읽는 상태가 곧 갈라지기 때문입니다. 엑셀 가격표 만들기처럼 숫자를 한 번 박아 두는 표와 달리, 웹의 요금표는 고르는 동안에도 값이 계속 움직이기 때문에 그 값이 어디에 적혀 있는지가 나머지 전부를 정합니다. 요금표가 사람 카드로 시작하는 경우는 프로필 카드 디자인이 앞칸이고, 값을 정한 뒤의 설득은 랜딩페이지 전환율을 끌어올리는 섹션이 맡습니다.

01월·연 전환에 숫자가 굴러간다

무엇이 보이나

스위치를 누르면 월 29달러가 연간 결제 기준 23달러로 바뀌는데, 값이 툭 갈아 끼워지지 않고 한 자리씩 굴러 내려갑니다. 등록하지 않은 커스텀 속성은 애니메이션이 보간하지 못하므로, @property로 --n을 정수로 등록하고 그 값을 카운터로 그립니다.

월·연 전환에 숫자가 굴러간다
새 창에서 열기

01 · 월·연 전환에 숫자가 굴러간다 — 측정 누적면적 3.012% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

@propertycounter-resetaria-checked

코드에서 볼 곳

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 하나가 어느 장을 보일지 정합니다.

통화를 바꾸면 기호와 금액이 같이 바뀐다
새 창에서 열기

02 · 통화를 바꾸면 기호와 금액이 같이 바뀐다 — 측정 누적면적 8.975% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

aria-expandedaria-selectedpadding-bottom

코드 읽는 법

고른 뒤 초점을 트리거로 되돌릴 때는 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 좌석"이 됩니다.

쓰는 만큼 가격이 다시 계산된다
새 창에서 열기

03 · 쓰는 만큼 가격이 다시 계산된다 — 측정 누적면적 5.301% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

aria-valuetextscaleXtransform-origin

어디를 바꾸면 되나

누적면적 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()를 붙여야 이깁니다.

추천 플랜만 커지고 이웃은 물러난다
새 창에서 열기

04 · 추천 플랜만 커지고 이웃은 물러난다 — 측정 누적면적 11.803% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

:has()scalerotate

코드에서 볼 곳

리본은 카드 위로 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도로 묶어 둔 이유는 각도가 커질수록 카드가 모서리에서 본 그림에 가까워지기 때문입니다.

커서를 따라 기울어지는 플랜 카드
새 창에서 열기

05 · 커서를 따라 기울어지는 플랜 카드 — 측정 누적면적 24.617% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

perspectiverotateYpointermove

코드 읽는 법

커서를 따라가는 동안에는 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으로 되돌려 다섯 줄이 동시에 움직이므로 낱개 속성으로만 씁니다.

포함 기능이 한 줄씩 켜진다
새 창에서 열기

06 · 포함 기능이 한 줄씩 켜진다 — 측정 누적면적 3.764% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

animation-delayanimation-fill-mode--i

어디를 바꾸면 되나

지연 구간에는 애니메이션이 적용되지 않아 첫 프레임에 완성된 줄이 번쩍이는데, 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로 보간될 뿐입니다.

비교표가 묶음째 접히고 펼쳐진다
새 창에서 열기

07 · 비교표가 묶음째 접히고 펼쳐진다 — 측정 누적면적 62.638% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

grid-template-rowsscope="row"aria-controls

코드에서 볼 곳

표는 <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을 더한 값)으로 나눠 반올림해 가장 가까운 카드로 붙입니다.

손가락으로 넘기는 플랜 카드
새 창에서 열기

08 · 손가락으로 넘기는 플랜 카드 — 측정 누적면적 20.361% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

setPointerCapturetranslateXaria-current

코드 읽는 법

스크롤 상자와 트랙을 반드시 다른 요소로 두는데, 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이라 라이브 영역이 새 문장 하나만 읽습니다.

결제 버튼이 기다렸다가 결과를 보여준다
새 창에서 열기

09 · 결제 버튼이 기다렸다가 결과를 보여준다 — 측정 누적면적 4.831% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

aria-busysteps(1, end)aria-live

어디를 바꾸면 되나

로딩 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 안에서 숫자를 직접 써 넣는 방법밖에 없는데, 그러면 프레임마다 텍스트 노드를 갈아 끼우게 됩니다. 전환 자체에만 초점을 맞춘 애니메이션 판은 CSS 가격표 디자인 애니메이션에 있습니다.

월·연 토글에 체크박스 대신 role="switch"를 쓴 이유가 뭔가요

결제 주기는 켜고 끄는 값이 아니라 둘 중 하나를 고르는 값이라 라디오도 후보입니다. 다만 선택지가 정확히 둘이고 화면에 알약 하나만 두는 형태라면 스위치가 읽히는 문장이 짧습니다. 셋 이상이 되면 그때는 라디오 그룹으로 바꾸는 편이 낫습니다.

무한 캐러셀처럼 끝에서 처음으로 되감고 싶으면 어떻게 하나요

08번은 일부러 되감지 않습니다. 되감으려면 첫 카드의 복제본을 끝에 붙여 두었다가 몰래 자리를 바꾸는 방식이 흔한데, 그 순간 스크린리더에는 같은 카드가 두 번 읽히고 키보드 초점도 복제본에 갇힙니다. 플랜이 셋뿐인 요금표에서는 양 끝에서 멈추는 편이 잃는 게 적습니다.

압축 비밀번호를 입력하세요

비밀번호는 이 글 본문 안에 있습니다. 읽다 보면 나옵니다.