GODRICH

장바구니 UI 9종 — 복붙하면 되는 수량·쿠폰·결제 코드

장바구니 UI 는 손님이 물건을 담은 뒤 결제 버튼을 누르기 직전까지 마주치는 화면 묶음입니다. 수량을 바꾸면 합계가 따라오는 줄, 쿠폰을 넣으면 끼어드는 할인 줄, 결제 수단을 고르면 펼쳐지는 카드 같은 것들인데, 여기 모은 아홉 벌은 복붙하면 되는 HTML·SCSS·JS 로 짜여

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

아홉 개는 기술 난이도 순이 아니라 주문이 완성되기까지의 순서로 놓았습니다. 장바구니에서 수량과 소계가 함께 물드는 줄(01)에서 시작해, 나중에 담기를 고르면 줄이 옮겨가고(02), 쿠폰이 요약 사이에 끼어들고(03), 재고 한도가 수량을 되튕기는가 하면(04) 결제 수단 카드가 열리고(05), 배송지가 접히고(06), 도착 날짜가 밀려 바뀌고(07), 약관 동의가 차례로 켜진 뒤(08)에야 하단 결제 바가 자리를 내주고 물러납니다(09). 아홉 벌이 같은 색·간격·이징 값을 나눠 쓰기 때문에 몇 개를 한 결제 화면에 섞어도 톤이 어긋나지 않습니다.

01수량을 바꾸면 소계와 합계가 함께 물드는 줄

수량 칸의 값을 바꾸면 그 줄 소계와 아래 합계가 같은 순간에 다시 계산되고, 바뀐 숫자 칸 두 곳만 background-color 로 물들었다가 transition 으로 식습니다. 숫자는 font-variant-numeric: tabular-nums 로 자릿수가 늘어나도 칸 폭이 흔들리지 않고, 누적 변화 면적은 화면의 2.699% 로 측정됐습니다. 쇼핑몰 장바구니 줄이나 예약 인원 수 변경 화면에 씁니다.

background-colortransitiontabular-nums
// 숫자 칸 — 자릿수가 바뀌어도 폭이 흔들리지 않게 tabular-nums + min-width
.qs__sum {
  min-width: 58px;
  padding: $sp-1;
  border-radius: $r-xs;
  text-align: right;
  font-variant-numeric: tabular-nums;
  // 실제 클릭 경로: 배경을 한 번 켜고(transition: none) 클래스를 떼면 이 transition 이 식힌다
  background-color: rgba($stage-yellow, 0);
  transition: background-color $dur-enter $ease-out;
  &.is-hot {
    background-color: rgba($stage-yellow, .62);
    transition: none;
  }
}

02나중에 담기로 미끄러져 내려가는 줄

나중에 담기를 누르면 그 줄이 높이를 잃으며 접히고, 아래 보관 묶음에 같은 상품이 위에서 내려앉아 두 목록의 개수가 함께 바뀝니다. 줄 하나가 grid-template-rows: 1fr → 0fr 로 접히는 동시에 짝이 0fr → 1fr 로 열리므로 카드 전체 높이는 그대로이고, 누적면적 11.938% · 강도 104.9 로 측정됐습니다. 장바구니의 나중에 구매 목록이나 위시리스트로 옮기기에 씁니다.

grid-template-rows0frtranslateY
// 줄 하나 = 높이를 주고받는 칸. 실제 클릭 경로는 transition 으로 부드럽게 접힌다
.mv__row {
  display: grid;
  grid-template-rows: 1fr;
  transition: grid-template-rows $dur-base $easing;
}
.mv__row.is-out { grid-template-rows: 0fr; }
// 접히는 칸에는 패딩을 두지 않는다 — 0fr 이 되어도 세로 패딩은 그대로 남는다.
// 안쪽을 감싸는 이 칸이 높이 0 이 되면서 패딩을 가진 진짜 줄을 잘라 낸다
.mv__box {
  min-height: 0;
  overflow: hidden;
}

03요약 사이로 끼어드는 할인 줄

쿠폰 코드를 넣고 적용하면 요약의 상품 금액과 합계 사이에 할인 줄이 높이 0에서 펴지며 끼어들고, 아래 줄들이 그만큼 밀려 내려갑니다. 결과 문구는 aria-live 영역으로 읽히고 합계 숫자도 같은 계산으로 바뀌며, 누적면적 15.799% · 강도 110.8 로 측정됐습니다. 쿠폰·프로모션 코드 입력칸이 있는 결제 요약에 씁니다.

grid-template-rowsaria-liveopacity
.cp__disc {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows $dur-base $easing;
}
.cp.is-on .cp__disc { grid-template-rows: 1fr; }
function cpApply() {
  cp.classList.remove('is-demo');
  var code = cpCode.value.trim().toUpperCase();
  var on = code === cpValidCode;
  cp.classList.toggle('is-on', on);
  cpTotalValue.textContent = cpMoney(cpAmount(cpItems.textContent) - (on ? cpAmount(cpOff.textContent) : 0));
  cpMsg.textContent = on ? cpOkText : (code ? cpWrongText : cpEmptyText);
}

04재고를 넘으면 되튕겨 돌아오는 수량

남은 재고보다 큰 수를 넣으면 숫자가 최대치로 한 번 되튕기고 줄 위에 경고 띠가 내려와 남은 개수를 말해 줍니다. 되튕김은 cubic-bezier 오버슛 이징의 translateY 이고 띠는 role="alert" 로 읽히며, 띠가 설 자리 24px 을 미리 확보해 상품 이름을 덮지 않게 했습니다. 누적면적 5.05% · 강도 123.9 로 측정된 이 벌은 재고 한정 상품이나 1인당 구매 수량 제한이 있는 특가에 씁니다.

translateYrole="alert"cubic-bezier
// 되튕김 — 최대치로 돌아오며 제자리를 한 번 지나친다 (오버슛 이징)
@keyframes cs-bounce {
  0%, 25% { transform: translateY(0) scale(1); }
  29% { transform: translateY(-10px) scale(1.16); }
  40%, 100% { transform: translateY(0) scale(1); }
}

05고른 결제 수단만 아래로 펼쳐지는 카드

결제 수단 카드 세 장 중 하나를 고르면 그 카드 아래 상세 칸만 높이 0에서 펴지고, 앞서 열려 있던 칸은 같은 시간 동안 닫힙니다. 여닫는 힘은 라디오의 :checked 가 내고 키보드 화살표 이동에도 aria-expanded 값이 따라오며, 누적면적 17.7% · 움직인 프레임 8/23 으로 측정됐습니다. 카드·간편결제·계좌이체를 고르는 결제 화면에 씁니다.

grid-template-rowsaria-expanded:checked
// 상세 칸 — 닫힘 0fr, 열림 1fr. 높이를 px 로 짐작하지 않으니 글자가 길어져도 맞는다
.pm__fold {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows $duration $easing;
}
.pm__item:has(.pm__radio:checked) .pm__fold { grid-template-rows: 1fr; }
.pm__detail {
  min-height: 0;
  overflow: hidden;
}

06주문자와 같음을 켜면 접히며 채워지는 배송지

주문자와 같음을 켜면 배송지 칸 묶음이 접히면서 요약 한 줄로 줄고, 그 줄에 주문자 이름과 주소가 이미 들어와 있습니다. 값 복사는 JS 가 하고 접힌 칸은 aria-hidden="true"inert 로 초점에서 빼서 탭 키가 빈 칸을 헤매지 않게 했으며, 누적면적 22.416% · 강도 55.7 로 측정됐습니다. 주문자·배송지를 따로 받는 주문서나 청구지 주소 폼에 씁니다.

grid-template-rowsaria-hiddeninert
function syncShipping() {
  sa.classList.remove('is-demo');                 // 사람이 손대면 미리보기 루프는 끝난다
  var folded = same.checked;
  fold.setAttribute('aria-hidden', folded ? 'true' : 'false');
  fold.toggleAttribute('inert', folded);          // 접힌 칸은 탭으로도 안 들어간다
  if (folded) {
    document.getElementById('saName').value = whoName.textContent.trim();
    document.getElementById('saAddr').value = whoAddr.textContent.trim();
    sum.textContent = whoName.textContent.trim() + ' · ' + whoAddr.textContent.trim();
  }
}

07배송 방법을 바꾸면 날짜가 밀려 바뀌는 도착 칩

일반·빠른·새벽 중 하나를 고르면 도착 예정 칩 안의 날짜가 위로 밀려 나가고 새 날짜가 아래에서 올라와 자리를 잡습니다. 칩은 overflow: hidden 창 안에서 줄판이 translateY 로 미는 오도미터라 숫자가 겹쳐 보이는 중간 프레임이 없고, 바뀐 결과는 aria-live 칸이 한 문장으로 읽으며 배송비도 같이 바뀌고, 누적면적 9.291% · 움직인 프레임 12/23 으로 측정됐습니다. 배송 방법별 도착 예정일 안내나 픽업 시간 선택에 씁니다.

overflowtranslateYaria-live
// 창은 한 줄만 보여 준다 — 줄판이 위로 밀려 나가고 다음 줄이 아래에서 올라온다
.sp__win {
  height: 16px;
  overflow: hidden;
}
.sp__roll {
  display: block;
  transform: translateY(calc(var(--i, 0) * -33.3333%));
  transition: transform $dur-base $easing;
}

08전체 동의가 아래를 차례로 켜는 약관 묶음

전체 동의를 켜면 아래 항목 네 개의 체크 표시가 순서대로 그려지고, 그중 하나를 끄면 전체 동의만 조용히 풀립니다. 체크는 SVG polylinestroke-dashoffset: 1 → 0 으로 그리고 시차는 transition-delay 한 줄로 주며, 「몇 개는 켜져 있다」 는 중간 상태는 스위치의 indeterminate 가 갖습니다. 누적면적 1.8% · 강도 122.3 · 움직인 프레임 19/23 으로 측정됐고, 결제 전 약관 동의 묶음이나 회원가입 필수·선택 동의에 씁니다.

stroke-dashoffsetanimation-delayindeterminate
<label class="ta__mark" for="ta-1" aria-hidden="true">
  <svg viewBox="0 0 24 24" aria-hidden="true">
    <polyline class="ta__tick" points="5 12.5 10 17 19 7" pathLength="1"
      fill="none" stroke="currentColor" stroke-width="3"
      stroke-linecap="round" stroke-linejoin="round"/>
  </svg>
</label>
.ta__tick {
  stroke-dashoffset: 1;
  transition: stroke-dashoffset $dur-base $easing;
  transition-delay: calc(var(--i, 0) * 60ms);
}
.ta__box:checked ~ .ta__mark .ta__tick { stroke-dashoffset: 0; }

09요약이 보이면 내려가 앉는 하단 결제 바

화면 아래 붙어 있던 합계·결제 바가 주문 요약이 시야에 들어오는 순간 아래로 내려가고, 요약이 다시 나가면 제자리로 올라옵니다. 보임 여부는 IntersectionObserver 가 판정하고 바는 스크롤 상자 밖에 position: absolute 로 겹쳐 두어 스크롤이 0 인 첫 장면에서도 서 있으며, 요약 카드가 내려간 자리를 채워 누적면적 25.205% · 강도 85.2 · 움직인 프레임 16/23 으로 측정됐습니다. 모바일 결제 화면의 고정 결제 버튼이나 긴 주문서의 합계 바에 씁니다.

IntersectionObservertranslateYposition
if (window.IntersectionObserver) {
  var watcher = new IntersectionObserver(function (entries) {
    for (var i = 0; i < entries.length; i++) {
      co.classList.toggle('is-down', entries[i].isIntersecting);
    }
  }, { root: view, threshold: 0.45 });
  watcher.observe(summary);
}

어디서 깨지는가 — 함정

가장 먼저 걸린 것은 0fr 이었습니다. 여닫는 칸을 grid-template-rows: 0fr ↔ 1fr 로 접는 방식을 02·03·05·06 네 항목에서 쓰는데, 처음에는 접히는 칸 자체에 세로 패딩을 줬다가 칸이 완전히 닫히지 않고 8px짜리 얇은 띠가 남았습니다. 0fr 은 그 칸의 콘텐츠 높이를 0 으로 만들 뿐 패딩은 그대로 두기 때문입니다. 고친 방법은 구조를 둘로 나누는 것입니다 — 바깥 칸은 grid-template-rowsmin-height: 0 만 갖고 패딩은 안쪽 줄에 둡니다. 바깥 칸이 높이 0 이 되면서 패딩을 가진 진짜 줄을 잘라 내므로 칸이 소리 없이 완전히 닫힙니다.

두 번째는 대표 이미지가 사라지는 쪽을 보여 주는 문제였습니다. poster 는 앞 프레임과 가장 크게 달라진 장을 뽑는데, 퇴장은 등장보다 변화량이 큰 경우가 많아 04 의 경고 띠가 물러나는 장면이 계속 이겼습니다. 물러남을 네 프레임에 걸쳐 천천히 하고 내려오는 순간을 한 프레임 컷으로 두어 띠가 서 있는 장이 다시 이기게 했고, 복귀는 2초 루프의 89% 안에 끝냈습니다. 02 와 03 도 같은 이유로 돌아오는 쪽을 70~86% 에 천천히 풀었습니다. 09 는 아예 설계를 바꿔 요약 카드가 바가 내려간 자리를 채우게 했는데, 그러지 않으면 「바가 사라진 빈 화면」 이 대표 이미지로 나갑니다.

세 번째는 겹쳐 둔 숫자가 반투명 유령이 되는 것이었습니다. 01 은 미리보기 루프에서 수량 2→3 을 보여 주려고 두 숫자를 같은 칸에 포개 두었는데, 처음엔 opacity 로 교차시켰다가 두 숫자가 겹쳐 읽히는 프레임이 그대로 poster 가 됐습니다. 숫자·글자 교체는 전부 steps(1, end) 컷으로 갈아 끼우고, 컷만 있으면 움직인 프레임이 모자라 측정이 안 차므로 칸이 살짝 솟았다 내려오는 translateY 를 곁들였습니다. 05 도 펼침 전환을 컷으로 하고 펼쳐진 글이 한 번 올라서며 프레임을 채웠습니다.

네 번째는 미리보기 루프를 뗀 뒤에야 드러났습니다. 03 의 미리보기에서는 입력칸 위에 코드 글자 SAVE3000 을 얹어 두는데, 이걸 display: none 으로 숨기면 정적 경로에서 입력칸의 모양이 흔들립니다. color: transparent 로 색만 지워 자리를 남겼고, 04 의 「넣어 본 값」 도 같은 방식입니다. 06 은 다른 종류의 사고를 미리 피했습니다 — 요약 줄을 세로로 올리면 overflow: hidden 칸에 반쯤 잘린 장면이 poster 로 뽑히므로 가로 슬라이드로 들어오게 했습니다.

마지막은 무대 색이 아니라 무대 밖이었습니다. 04 의 경고 띠는 절대배치로 상품 줄 위에 내려오는데, 처음에는 줄을 덮어 버려 무엇을 담았는지가 대표 이미지에서 사라졌습니다. 위쪽 24px 을 띠가 설 자리로 미리 확보하고 배경을 줄에 옮겼습니다. 09 의 바도 position: absolute 라 스크롤 상자와 겹치는데, 스크롤이 0 일 때도 서 있어야 하므로 sticky 대신 밖에 두는 쪽이 정답이었습니다 — sticky 는 미리보기의 스크롤 0 상태에서는 아예 걷지 않아 24프레임 어디에도 나오지 않습니다. 아홉 벌의 소스는 이 글이 측정한 값 그대로 zip 에 들어 있고, 여는 비밀번호는 be3rd37n 입니다.

접근성

움직임을 줄여 달라는 설정(prefers-reduced-motion: reduce)에서 아홉 개가 무엇을 끄고 무엇을 남기는지는 항목마다 다릅니다. 공통 원칙은 하나입니다 — 스스로 도는 움직임은 끄고, 손님이 조작한 결과(숫자·상태·문구)는 남깁니다. 01 은 숫자 교체와 물듦 애니메이션을 끄고 겹쳐 둔 두 번째 숫자만 감춘 채 현재 값을 보여 줍니다. 02 는 줄 접힘을 멈추고 두 목록의 개수를 보여 줍니다. 03 은 할인 줄이 펴진 상태로 멈춥니다. 04 는 되튕김을 끄고 띠·붉은 테두리·최대치 숫자를 남깁니다. 05 는 카드 하나가 골라져 펼쳐진 상태를 보여 줍니다. 06 은 접힌 배송지와 요약 한 줄을 보여 줍니다. 07 은 굴림을 끄고 현재 도착 날짜를 보여 줍니다. 08 은 체크 그려짐을 끄고 네 칸이 켜진 상태를 보여 줍니다. 09 는 바가 내려간 상태를 보여 줍니다. 이 reduce 블록의 선언에는 전부 !important 가 붙어 있는데, 미리보기 루프 쪽 규칙이 클래스를 겹쳐 쌓은 모양이라 특이도에서 이기기 때문입니다. 기준은 MDN 의 prefers-reduced-motion 설명을 따랐고, 09 가 쓰는 관찰자는 MDN 의 Intersection Observer API 문서에 정리되어 있습니다.

화면을 못 보는 사람에게 결과를 전하는 장치는 항목마다 다릅니다.

항목 실제로 붙은 것 무엇을 알리나
01 수량 소계 동기 수량 버튼 aria-label 어느 상품의 수량을 늘리는지·줄이는지
02 나중에 담기 이동 버튼 aria-label 어느 상품을 어느 목록으로 옮기는지
03 쿠폰 할인 줄 aria-live="polite" 적용됐는지·없는 코드인지
04 재고 한도 role="alert" 남은 수량과 되돌린 최대치
05 결제 수단 aria-expanded · 라디오 그룹 어느 수단이 펼쳐져 있는지
06 주문자와 같음 aria-hidden · inert 접힌 칸은 초점에서 빠진다는 것
07 도착 칩 aria-live="polite" 바뀐 도착 날짜와 배송비
08 약관 묶음 indeterminate 몇 개가 켜져 있는지
09 결제 바 role="region" · 스크롤 창 tabindex 주문서 영역과 하단 바의 존재

폼을 아직 안 만들었다면 폼 유효성 검사 UI 9종이 입력 칸 쪽 이웃이고, 여러 걸음 주문서는 다단계 폼 마법사 9종에 정리되어 있습니다. 입력 창 겉모습은 입력창 디자인 9종에 모여 있고, 표 형태 주문 목록은 데이터 테이블 인터랙션 9종이 이웃입니다. 이 사이트가 데모를 어떻게 렌더하고 잰 뒤에 본문을 쓰는지는 소개에 적어 두었습니다.

FAQ

장바구니 줄을 지우지 않고 접히게 하는 이유가 무엇인가요?

줄을 DOM 에서 바로 지우면 아래 내용이 한 번에 뛰어올라 손님이 무엇이 사라졌는지 놓칩니다. grid-template-rows: 0fr 로 접히면 줄이 차지하던 공간이 부드럽게 사라져 시선이 그 움직임을 따라가고, 짝이 되는 목록에서 같은 상품이 나타나므로 「옮겨갔다」 는 사실 자체가 움직임으로 설명됩니다.

쿠폰 적용 버튼을 눌러도 할인 줄이 안 보이는데 왜 그런가요?

할인 줄이 끼어드는 칸은 grid-template-rows: 0fr 로 시작하는데, 안쪽 요소에 min-height: 0 이 빠지면 그 칸은 0 으로 줄지 않고 내용 높이만큼 펴진 채로 남습니다. 데모의 .pm__detail 처럼 접히는 칸의 안쪽에는 항상 min-height: 0overflow: hidden 을 함께 두어야 0fr 과 1fr 사이가 왕복합니다.

결제 화면에 아홉 개를 다 넣어야 하나요?

아니요, 아홉 개는 재료입니다. 최소 구성은 01(수량·합계) · 08(약관) · 09(결제 바) 세 벌이고, 쿠폰을 쓴다면 03, 배송지를 따로 받는다면 06 을 더합니다. 전부 한 화면에 넣으면 그림이 복잡해지므로 결제 흐름의 한 걸음마다 한두 벌씩 나눠 붙이는 것을 권합니다.

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

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