Checkout Page Design — 9 Cart Patterns, No Library
Checkout page design covers every screen between "I want this" and the pay button.
01 cart-qty-subtotal-sync
02 cart-move-to-saved
03 cart-coupon-discount-line
04 cart-stock-limit-clamp
05 pay-method-card-expand
06 ship-same-address-collapse
07 ship-speed-eta-swap
08 terms-agree-all-cascade
09 checkout-sticky-total-bar
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Quantity change that tints the line total and the grand total together
- 02 Line item that slides down into the saved-for-later group
- 03 Discount line that wedges into the order summary
- 04 Quantity that springs back when it passes the stock limit
- 05 Payment method card that opens only the option you picked
- 06 Shipping fields that fold away and fill themselves in
- 07 Arrival chip whose date slides over when you change the shipping speed
- 08 Agree-to-all switch that lights the items below in turn
- 09 Bottom pay bar that steps aside once the summary is on screen
The nine checkout ui design steps follow the path of a checkout, not a difficulty ladder. That path starts in the cart, where the quantity and subtotal tint together (01), a line slides down into the saved-for-later group (02), a coupon wedges into the summary (03), and a stock limit bounces the quantity back (04). It continues through payment, where the chosen method card opens (05), the shipping fields fold away when they match the orderer (06), the arrival date rolls over with each shipping speed (07), and the consent items light up in turn (08), before the bottom pay bar finally steps aside for the summary (09). All nine share one set of colors, spacing, and easing values, so a handful of them on one checkout screen still keeps a single tone. When the order form itself has to split into steps, the multi-step form wizard owns the branching and the way back, and an address picked on a map belongs to the map location picker.
01Quantity change that tints the line total and the grand total together
What this checkout page design ui shows
Change the quantity, and the line subtotal and the grand total recalculate in the same tick, while only those two number cells tint with background-color and cool back down through the transition. The numbers use font-variant-numeric: tabular-nums so a wider digit never shakes the cell, and the render logged a cumulative changed area of 2.699%.

<main class="stage">
<section class="qs is-demo" id="qs" aria-label="Cart quantity and totals">
<header class="qs__head">
<p class="qs__title">Cart</p>
<p class="qs__hint">Tap the quantity</p>
</header>
<ul class="qs__list">
<li class="qs__row" data-qty="2">
<span class="qs__info">
<span class="qs__name">Basic tee</span>
<span class="qs__unit">12,000 KRW</span>
</span>
<span class="qs__step">
<button class="qs__b qs__b--down" type="button" data-step="-1" aria-label="Decrease Basic tee quantity"><svg viewBox="0 0 256 256" width="12" height="12" fill="currentColor" aria-hidden="true"><path d="M216,56V200a16,16,0,0,1-16,16H56a16,16,0,0,1-16-16V56A16,16,0,0,1,56,40H200A16,16,0,0,1,216,56Z" opacity="0.2"/><path d="M224,128a8,8,0,0,1-8,8H40a8,8,0,0,1,0-16H216A8,8,0,0,1,224,128Z"/></svg></button>
<output class="qs__q qs__swap"><span class="qs__v">2</span><span class="qs__alt" aria-hidden="true">3</span></output>
<button class="qs__b qs__b--up" type="button" data-step="1" aria-label="Increase Basic tee quantity"><svg viewBox="0 0 256 256" width="12" height="12" fill="currentColor" aria-hidden="true"><path d="M216,56V200a16,16,0,0,1-16,16H56a16,16,0,0,1-16-16V56A16,16,0,0,1,56,40H200A16,16,0,0,1,216,56Z" opacity="0.2"/><path d="M224,128a8,8,0,0,1-8,8H136v80a8,8,0,0,1-16,0V136H40a8,8,0,0,1,0-16h80V40a8,8,0,0,1,16,0v80h80A8,8,0,0,1,224,128Z"/></svg></button>
</span>
<span class="qs__sum qs__swap"><span class="qs__v qs__money">24,000 KRW</span><span class="qs__alt" aria-hidden="true">36,000 KRW</span></span>
</li>
<li class="qs__row" data-qty="1">
<span class="qs__info">
<span class="qs__name">Plain socks</span>
<span class="qs__unit">4,000 KRW</span>
</span>
<span class="qs__step">
<button class="qs__b qs__b--down" type="button" data-step="-1" aria-label="Decrease Plain socks quantity"><svg viewBox="0 0 256 256" width="12" height="12" fill="currentColor" aria-hidden="true"><path d="M216,56V200a16,16,0,0,1-16,16H56a16,16,0,0,1-16-16V56A16,16,0,0,1,56,40H200A16,16,0,0,1,216,56Z" opacity="0.2"/><path d="M224,128a8,8,0,0,1-8,8H40a8,8,0,0,1,0-16H216A8,8,0,0,1,224,128Z"/></svg></button>
<output class="qs__q"><span class="qs__v">1</span></output>
<button class="qs__b qs__b--up" type="button" data-step="1" aria-label="Increase Plain socks quantity"><svg viewBox="0 0 256 256" width="12" height="12" fill="currentColor" aria-hidden="true"><path d="M216,56V200a16,16,0,0,1-16,16H56a16,16,0,0,1-16-16V56A16,16,0,0,1,56,40H200A16,16,0,0,1,216,56Z" opacity="0.2"/><path d="M224,128a8,8,0,0,1-8,8H136v80a8,8,0,0,1-16,0V136H40a8,8,0,0,1,0-16h80V40a8,8,0,0,1,16,0v80h80A8,8,0,0,1,224,128Z"/></svg></button>
</span>
<span class="qs__sum"><span class="qs__v qs__money">4,000 KRW</span></span>
</li>
</ul>
<p class="qs__total">
<span class="qs__tlabel">Total</span>
<output class="qs__sum qs__sum--grand qs__swap" id="qs-grand"><span class="qs__v" id="qs-grandv">28,000 KRW</span><span class="qs__alt" aria-hidden="true">40,000 KRW</span></output>
</p>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 수량을 바꾸면 그 줄 소계와 아래 합계가 같은 순간에 다시 계산되고,
// 바뀐 숫자 칸 두 곳만 background-color 가 켜졌다 transition 으로 식는다.
// 숫자는 font-variant-numeric: tabular-nums 라 자릿수가 늘어도 칸이 흔들리지 않는다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $stage-yellow;
@include stage($stage-ink);
.qs {
box-sizing: border-box;
width: min(300px, 92vw);
padding: $sp-3;
border-radius: $r-card;
background: $subject-cream;
color: $subject-ink;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.qs * { box-sizing: border-box; }
.qs__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: $sp-2;
}
.qs__title {
margin: 0;
font-size: 13px;
font-weight: 700;
line-height: 1.3;
}
.qs__hint {
margin: 0;
font-size: 10px;
font-weight: 500;
line-height: 1.3;
color: rgba($subject-ink, .72);
}
.qs__list {
display: grid;
gap: $sp-2;
margin: $sp-2 0 0;
padding: 0;
list-style: none;
}
.qs__row {
display: grid;
grid-template-columns: 1fr auto auto;
align-items: center;
gap: $sp-2;
}
.qs__info {
display: grid;
min-width: 0;
}
.qs__name {
font-size: 11px;
font-weight: 700;
line-height: 1.35;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.qs__unit {
font-size: 10px;
font-weight: 500;
line-height: 1.35;
color: rgba($subject-ink, .72);
font-variant-numeric: tabular-nums;
}
.qs__step {
display: inline-flex;
align-items: center;
gap: $sp-1;
padding: $sp-1;
border-radius: $r-pill;
background: rgba(23, 20, 26, .07);
}
.qs__b {
display: inline-grid;
place-items: center;
width: 20px;
height: 20px;
padding: 0;
margin: 0;
border: 0;
border-radius: 50%;
background: $subject-cream;
color: $subject-ink;
cursor: pointer;
&:focus-visible { outline: 2px solid $subject-blue; outline-offset: 2px; }
}
// 숫자 칸 — 자릿수가 바뀌어도 폭이 흔들리지 않게 tabular-nums + min-width
.qs__q {
position: relative;
min-width: 18px;
text-align: center;
font-size: 12px;
font-weight: 700;
line-height: 1.4;
font-variant-numeric: tabular-nums;
}
.qs__sum {
position: relative;
justify-self: end;
min-width: 58px;
padding: $sp-1;
border-radius: $r-xs;
text-align: right;
font-size: 11px;
font-weight: 700;
line-height: 1.4;
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;
}
}
// 미리보기 루프에서만 쓰는 두 번째 숫자 — 정적 경로에서는 자리를 차지하지 않고 보이지도 않는다
.qs__alt {
position: absolute;
inset: 0;
display: grid;
place-items: center;
opacity: 0;
}
.qs__sum .qs__alt {
place-items: center end;
padding: $sp-1;
}
.qs__total {
display: flex;
align-items: center;
justify-content: space-between;
gap: $sp-2;
margin: $sp-2 0 0;
padding: $sp-2 0 0;
border-top: 1px solid rgba(23, 20, 26, .14);
}
.qs__tlabel {
font-size: 11px;
font-weight: 700;
line-height: 1.4;
}
.qs__sum--grand {
min-width: 68px;
font-size: 13px;
}
// ── 격자 미리보기 루프 (2초) ──
// 숫자 교체는 steps(1, end) 컷이다 — 크로스페이드로 겹치면 대표 이미지에 두 숫자가 반투명하게 겹친다.
// 컷만으로는 움직인 프레임이 모자라니 칸이 살짝 솟았다 내려오는 translateY 를 곁들인다.
@keyframes qs-a {
0%, 24% { opacity: 1; }
25%, 62% { opacity: 0; }
63%, 100% { opacity: 1; }
}
@keyframes qs-b {
0%, 24% { opacity: 0; }
25%, 62% { opacity: 1; }
63%, 100% { opacity: 0; }
}
@keyframes qs-tint {
0%, 24% { background-color: rgba($stage-yellow, 0); }
25% { background-color: rgba($stage-yellow, .62); }
54% { background-color: rgba($stage-yellow, 0); }
62% { background-color: rgba($stage-yellow, 0); }
63% { background-color: rgba($stage-yellow, .62); }
88%, 100% { background-color: rgba($stage-yellow, 0); }
}
@keyframes qs-pop {
0%, 24% { transform: translateY(0); }
25% { transform: translateY(-4px); }
40% { transform: translateY(0); }
62% { transform: translateY(0); }
63% { transform: translateY(-4px); }
78%, 100% { transform: translateY(0); }
}
@keyframes qs-press-up {
0%, 21% { transform: scale(1); }
25% { transform: scale(.78); }
37%, 100% { transform: scale(1); }
}
@keyframes qs-press-down {
0%, 59% { transform: scale(1); }
63% { transform: scale(.78); }
75%, 100% { transform: scale(1); }
}
.qs.is-demo {
.qs__swap .qs__v { animation-name: qs-a; }
.qs__swap .qs__alt { animation-name: qs-b; }
.qs__swap .qs__v,
.qs__swap .qs__alt {
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.qs__sum.qs__swap {
animation-name: qs-tint, qs-pop;
animation-duration: $duration, $duration;
animation-timing-function: $ease-out, $easing;
animation-iteration-count: infinite, infinite;
animation-fill-mode: backwards, backwards;
}
.qs__row:first-child .qs__b--up { animation-name: qs-press-up; }
.qs__row:first-child .qs__b--down { animation-name: qs-press-down; }
.qs__row:first-child .qs__b {
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
}
@media (max-width: 340px) {
.qs { padding: $sp-2; }
.qs__sum { min-width: 52px; }
.qs__sum--grand { min-width: 60px; }
.qs__b { width: 18px; height: 18px; }
}
@media (prefers-reduced-motion: reduce) {
.qs.is-demo .qs__v,
.qs.is-demo .qs__b,
.qs.is-demo .qs__sum {
animation: none !important;
transform: none !important;
}
.qs.is-demo .qs__v { opacity: 1 !important; }
// 정지 상태에서도 값은 보인다 — 겹쳐 둔 두 번째 숫자만 계속 감춘다
.qs.is-demo .qs__alt { animation: none !important; opacity: 0 !important; }
.qs.is-demo .qs__sum { background-color: rgba($stage-yellow, 0) !important; }
.qs__sum { transition: none !important; }
}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);
}
.qs {
box-sizing: border-box;
width: min(300px, 92vw);
padding: 12px;
border-radius: 16px;
background: #fff7e6;
color: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.qs * {
box-sizing: border-box;
}
.qs__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.qs__title {
margin: 0;
font-size: 13px;
font-weight: 700;
line-height: 1.3;
}
.qs__hint {
margin: 0;
font-size: 10px;
font-weight: 500;
line-height: 1.3;
color: rgba(23, 20, 26, 0.72);
}
.qs__list {
display: grid;
gap: 8px;
margin: 8px 0 0;
padding: 0;
list-style: none;
}
.qs__row {
display: grid;
grid-template-columns: 1fr auto auto;
align-items: center;
gap: 8px;
}
.qs__info {
display: grid;
min-width: 0;
}
.qs__name {
font-size: 11px;
font-weight: 700;
line-height: 1.35;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.qs__unit {
font-size: 10px;
font-weight: 500;
line-height: 1.35;
color: rgba(23, 20, 26, 0.72);
font-variant-numeric: tabular-nums;
}
.qs__step {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.07);
}
.qs__b {
display: inline-grid;
place-items: center;
width: 20px;
height: 20px;
padding: 0;
margin: 0;
border: 0;
border-radius: 50%;
background: #fff7e6;
color: #17141a;
cursor: pointer;
}
.qs__b:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.qs__q {
position: relative;
min-width: 18px;
text-align: center;
font-size: 12px;
font-weight: 700;
line-height: 1.4;
font-variant-numeric: tabular-nums;
}
.qs__sum {
position: relative;
justify-self: end;
min-width: 58px;
padding: 4px;
border-radius: 4px;
text-align: right;
font-size: 11px;
font-weight: 700;
line-height: 1.4;
font-variant-numeric: tabular-nums;
background-color: rgba(255, 210, 63, 0);
transition: background-color 600ms ease-out;
}
.qs__sum.is-hot {
background-color: rgba(255, 210, 63, 0.62);
transition: none;
}
.qs__alt {
position: absolute;
inset: 0;
display: grid;
place-items: center;
opacity: 0;
}
.qs__sum .qs__alt {
place-items: center end;
padding: 4px;
}
.qs__total {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin: 8px 0 0;
padding: 8px 0 0;
border-top: 1px solid rgba(23, 20, 26, 0.14);
}
.qs__tlabel {
font-size: 11px;
font-weight: 700;
line-height: 1.4;
}
.qs__sum--grand {
min-width: 68px;
font-size: 13px;
}
@keyframes qs-a {
0%, 24% {
opacity: 1;
}
25%, 62% {
opacity: 0;
}
63%, 100% {
opacity: 1;
}
}
@keyframes qs-b {
0%, 24% {
opacity: 0;
}
25%, 62% {
opacity: 1;
}
63%, 100% {
opacity: 0;
}
}
@keyframes qs-tint {
0%, 24% {
background-color: rgba(255, 210, 63, 0);
}
25% {
background-color: rgba(255, 210, 63, 0.62);
}
54% {
background-color: rgba(255, 210, 63, 0);
}
62% {
background-color: rgba(255, 210, 63, 0);
}
63% {
background-color: rgba(255, 210, 63, 0.62);
}
88%, 100% {
background-color: rgba(255, 210, 63, 0);
}
}
@keyframes qs-pop {
0%, 24% {
transform: translateY(0);
}
25% {
transform: translateY(-4px);
}
40% {
transform: translateY(0);
}
62% {
transform: translateY(0);
}
63% {
transform: translateY(-4px);
}
78%, 100% {
transform: translateY(0);
}
}
@keyframes qs-press-up {
0%, 21% {
transform: scale(1);
}
25% {
transform: scale(0.78);
}
37%, 100% {
transform: scale(1);
}
}
@keyframes qs-press-down {
0%, 59% {
transform: scale(1);
}
63% {
transform: scale(0.78);
}
75%, 100% {
transform: scale(1);
}
}
.qs.is-demo .qs__swap .qs__v {
animation-name: qs-a;
}
.qs.is-demo .qs__swap .qs__alt {
animation-name: qs-b;
}
.qs.is-demo .qs__swap .qs__v,
.qs.is-demo .qs__swap .qs__alt {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.qs.is-demo .qs__sum.qs__swap {
animation-name: qs-tint, qs-pop;
animation-duration: 2s, 2s;
animation-timing-function: ease-out, cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite, infinite;
animation-fill-mode: backwards, backwards;
}
.qs.is-demo .qs__row:first-child .qs__b--up {
animation-name: qs-press-up;
}
.qs.is-demo .qs__row:first-child .qs__b--down {
animation-name: qs-press-down;
}
.qs.is-demo .qs__row:first-child .qs__b {
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@media (max-width: 340px) {
.qs {
padding: 8px;
}
.qs__sum {
min-width: 52px;
}
.qs__sum--grand {
min-width: 60px;
}
.qs__b {
width: 18px;
height: 18px;
}
}
@media (prefers-reduced-motion: reduce) {
.qs.is-demo .qs__v,
.qs.is-demo .qs__b,
.qs.is-demo .qs__sum {
animation: none !important;
transform: none !important;
}
.qs.is-demo .qs__v {
opacity: 1 !important;
}
.qs.is-demo .qs__alt {
animation: none !important;
opacity: 0 !important;
}
.qs.is-demo .qs__sum {
background-color: rgba(255, 210, 63, 0) !important;
}
.qs__sum {
transition: none !important;
}
}// 수량을 바꾸면 그 줄 소계와 아래 합계를 같은 순간에 다시 계산한다.
// 금액 서식은 화면에 이미 있는 단가 글자에서 뽑는다 — 언어팩이 바꿔 둔 통화 표기를 그대로 따라간다
var qsRoot = document.getElementById('qs');
var qsRows = qsRoot.querySelectorAll('.qs__row');
var qsGrand = document.getElementById('qs-grand');
var qsGrandValue = document.getElementById('qs-grandv');
function qsComma(n) { return String(n).replace(/\B(?=(\d{3})+$)/g, ','); }
function qsUnitText(row) { return row.querySelector('.qs__unit').textContent.trim(); }
function qsAmount(text) { return Number(text.replace(/[^\d]/g, '')); }
function qsMoney(row, n) { return qsUnitText(row).replace(/[\d,]+/, qsComma(n)); }
function qsPaint(row) {
var qty = Number(row.getAttribute('data-qty'));
row.querySelector('.qs__q .qs__v').textContent = String(qty);
row.querySelector('.qs__money').textContent = qsMoney(row, qty * qsAmount(qsUnitText(row)));
}
function qsTotal() {
var sum = 0;
for (var i = 0; i < qsRows.length; i++) {
sum += Number(qsRows[i].getAttribute('data-qty')) * qsAmount(qsUnitText(qsRows[i]));
}
qsGrandValue.textContent = qsMoney(qsRows[0], sum);
}
// 물들었다 식는다 — 배경만 한 번 켜고 transition 이 되돌린다
function qsTint(cell) {
cell.classList.add('is-hot');
setTimeout(function () { cell.classList.remove('is-hot'); }, 80);
}
function qsBump(row, step) {
qsRoot.classList.remove('is-demo');
var qty = Math.min(9, Math.max(1, Number(row.getAttribute('data-qty')) + step));
row.setAttribute('data-qty', String(qty));
qsPaint(row);
qsTotal();
qsTint(row.querySelector('.qs__sum'));
qsTint(qsGrand);
}
qsRoot.addEventListener('click', function (e) {
var btn = e.target.closest('.qs__b');
if (btn) { qsBump(btn.closest('.qs__row'), Number(btn.getAttribute('data-step'))); }
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./CartQtySubtotalSync.module.scss";
export type CartQtyLine = {
id: string;
name: string;
/** 단가 글자 그대로 — 금액 서식(통화 기호·자릿점)을 여기서 뽑아 쓴다 */
unit: string;
qty: number;
downLabel: string;
upLabel: string;
};
export type CartQtySubtotalSyncProps = {
title?: string;
hint?: string;
totalLabel?: string;
groupLabel?: string;
lines?: CartQtyLine[];
/** 격자 미리보기용 2초 자동 루프. 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const DEFAULT_LINES: CartQtyLine[] = [
{ id: "tee", name: "기본 티셔츠", unit: "12,000원", qty: 2, downLabel: "기본 티셔츠 수량 줄이기", upLabel: "기본 티셔츠 수량 늘리기" },
{ id: "socks", name: "무지 양말", unit: "4,000원", qty: 1, downLabel: "무지 양말 수량 줄이기", upLabel: "무지 양말 수량 늘리기" },
];
function comma(n: number): string {
return String(n).replace(/\B(?=(\d{3})+$)/g, ",");
}
function amount(text: string): number {
return Number(text.replace(/[^\d]/g, ""));
}
// 금액 서식은 단가 글자를 그대로 본떠 만든다 — 언어팩이 바꿔 둔 통화 표기를 따라간다
function money(unit: string, n: number): string {
return unit.replace(/[\d,]+/, comma(n));
}
function MinusIcon() {
return (
<svg viewBox="0 0 256 256" width="12" height="12" fill="currentColor" aria-hidden="true"><path d="M216,56V200a16,16,0,0,1-16,16H56a16,16,0,0,1-16-16V56A16,16,0,0,1,56,40H200A16,16,0,0,1,216,56Z" opacity="0.2"/><path d="M224,128a8,8,0,0,1-8,8H40a8,8,0,0,1,0-16H216A8,8,0,0,1,224,128Z"/></svg>
);
}
function PlusIcon() {
return (
<svg viewBox="0 0 256 256" width="12" height="12" fill="currentColor" aria-hidden="true"><path d="M216,56V200a16,16,0,0,1-16,16H56a16,16,0,0,1-16-16V56A16,16,0,0,1,56,40H200A16,16,0,0,1,216,56Z" opacity="0.2"/><path d="M224,128a8,8,0,0,1-8,8H136v80a8,8,0,0,1-16,0V136H40a8,8,0,0,1,0-16h80V40a8,8,0,0,1,16,0v80h80A8,8,0,0,1,224,128Z"/></svg>
);
}
export default function CartQtySubtotalSync({
title = "장바구니",
hint = "수량을 눌러 보세요",
totalLabel = "합계",
groupLabel = "장바구니 수량과 합계",
lines = DEFAULT_LINES,
demo = true,
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#ffd23f",
}: CartQtySubtotalSyncProps) {
const [qty, setQty] = useState<number[]>(lines.map((l) => l.qty));
const [hot, setHot] = useState<string[]>([]);
const [live, setLive] = useState(demo);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const grand = lines.reduce((sum, l, i) => sum + qty[i] * amount(l.unit), 0);
// 미리보기 루프가 컷으로 갈아 끼우는 두 번째 값 — 첫 줄 수량이 하나 늘었을 때의 숫자
const altQty = lines[0].qty + 1;
const altGrand = lines.reduce((sum, l, i) => sum + (i === 0 ? altQty : l.qty) * amount(l.unit), 0);
// 수량·소계·합계를 같은 순간에 다시 계산하고, 바뀐 숫자 칸 두 곳만 배경을 한 번 켠다
function bump(index: number, step: number) {
setLive(false);
setQty((prev: number[]) => prev.map((q, k) => (k === index ? Math.min(9, Math.max(1, q + step)) : q)));
setHot([lines[index].id, "grand"]);
setTimeout(() => setHot([]), 80);
}
const cls = [styles.qs, live ? styles.isDemo : ""].filter(Boolean).join(" ");
const grandCls = [styles.sum, styles.grand, styles.swap, hot.indexOf("grand") >= 0 ? styles.isHot : ""].filter(Boolean).join(" ");
return (
<section className={cls} style={vars} aria-label={groupLabel}>
<header className={styles.head}>
<p className={styles.title}>{title}</p>
<p className={styles.hint}>{hint}</p>
</header>
<ul className={styles.list}>
{lines.map((line, i) => {
const swap = i === 0;
const sumCls = [styles.sum, swap ? styles.swap : "", hot.indexOf(line.id) >= 0 ? styles.isHot : ""].filter(Boolean).join(" ");
return (
<li className={styles.row} key={line.id}>
<span className={styles.info}>
<span className={styles.name}>{line.name}</span>
<span className={styles.unit}>{line.unit}</span>
</span>
<span className={styles.step}>
<button className={`${styles.btn} ${styles.btnDown}`} type="button" onClick={() => bump(i, -1)} aria-label={line.downLabel}><MinusIcon /></button>
<output className={`${styles.qty} ${swap ? styles.swap : ""}`}>
<span className={styles.value}>{qty[i]}</span>
{swap && live ? <span className={styles.alt} aria-hidden="true">{altQty}</span> : null}
</output>
<button className={`${styles.btn} ${styles.btnUp}`} type="button" onClick={() => bump(i, 1)} aria-label={line.upLabel}><PlusIcon /></button>
</span>
<span className={sumCls}>
<span className={styles.value}>{money(line.unit, qty[i] * amount(line.unit))}</span>
{swap && live ? <span className={styles.alt} aria-hidden="true">{money(line.unit, altQty * amount(line.unit))}</span> : null}
</span>
</li>
);
})}
</ul>
<p className={styles.total}>
<span className={styles.tlabel}>{totalLabel}</span>
<output className={grandCls}>
<span className={styles.value}>{money(lines[0].unit, grand)}</span>
{live ? <span className={styles.alt} aria-hidden="true">{money(lines[0].unit, altGrand)}</span> : null}
</output>
</p>
</section>
);
}01 · Quantity change that tints the line total and the grand total together — Measured area 2.699% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
Use it on cart line items or booking screens where the headcount changes.
// Number cell — tabular-nums + min-width so a wider digit never shakes the cell
.qs__sum {
min-width: 58px;
padding: $sp-1;
border-radius: $r-xs;
text-align: right;
font-variant-numeric: tabular-nums;
// Real click path: paint once (transition: none), then this transition cools it
background-color: rgba($stage-yellow, 0);
transition: background-color $dur-enter $ease-out;
&.is-hot {
background-color: rgba($stage-yellow, .62);
transition: none;
}
}
02Line item that slides down into the saved-for-later group
What happens on screen
Saving for later collapses the row's height while the same item settles into the saved group below, and both list counts change together. One row folds from grid-template-rows: 1fr to 0fr at the same moment its twin opens from 0fr to 1fr, so the card's overall height never moves, and the render logged 11.938% changed area at intensity 104.9.

<main class="stage">
<section class="mv is-demo" id="mv" aria-label="Cart and saved for later">
<div class="mv__group">
<p class="mv__head">
<span class="mv__label">Cart</span>
<output class="mv__n mv__swap"><span class="mv__v">2</span><span class="mv__alt" aria-hidden="true">1</span></output>
</p>
<ul class="mv__list" id="mv-cart">
<li class="mv__row" data-pair="p1">
<div class="mv__box"><div class="mv__in">
<span class="mv__name">Rattan basket</span>
<span class="mv__price">24,000 KRW</span>
<button class="mv__b" type="button" aria-label="Move Rattan basket to saved for later"><svg viewBox="0 0 256 256" width="13" height="13" fill="currentColor" aria-hidden="true"><path d="M192,48V224l-64-40L64,224V48a8,8,0,0,1,8-8H184A8,8,0,0,1,192,48Z" opacity="0.2"/><path d="M184,32H72A16,16,0,0,0,56,48V224a8,8,0,0,0,12.24,6.78L128,193.43l59.77,37.35A8,8,0,0,0,200,224V48A16,16,0,0,0,184,32Zm0,177.57-51.77-32.35a8,8,0,0,0-8.48,0L72,209.57V48H184Z"/></svg></button>
</div></div>
</li>
<li class="mv__row" data-pair="p2">
<div class="mv__box"><div class="mv__in">
<span class="mv__name">Linen apron</span>
<span class="mv__price">16,000 KRW</span>
<button class="mv__b" type="button" aria-label="Move Linen apron to saved for later"><svg viewBox="0 0 256 256" width="13" height="13" fill="currentColor" aria-hidden="true"><path d="M192,48V224l-64-40L64,224V48a8,8,0,0,1,8-8H184A8,8,0,0,1,192,48Z" opacity="0.2"/><path d="M184,32H72A16,16,0,0,0,56,48V224a8,8,0,0,0,12.24,6.78L128,193.43l59.77,37.35A8,8,0,0,0,200,224V48A16,16,0,0,0,184,32Zm0,177.57-51.77-32.35a8,8,0,0,0-8.48,0L72,209.57V48H184Z"/></svg></button>
</div></div>
</li>
</ul>
</div>
<div class="mv__group">
<p class="mv__head">
<span class="mv__label">Saved for later</span>
<output class="mv__n mv__swap"><span class="mv__v">0</span><span class="mv__alt" aria-hidden="true">1</span></output>
</p>
<ul class="mv__list" id="mv-saved">
<li class="mv__row mv__row--saved is-out" data-pair="p1">
<div class="mv__box"><div class="mv__in">
<span class="mv__name">Rattan basket</span>
<span class="mv__price">24,000 KRW</span>
<button class="mv__b mv__b--back" type="button" aria-label="Move Rattan basket back to the cart"><svg viewBox="0 0 256 256" width="13" height="13" fill="currentColor" aria-hidden="true"><path d="M224,64l-12.16,66.86A16,16,0,0,1,196.1,144H70.55L56,64Z" opacity="0.2"/><path d="M230.14,58.87A8,8,0,0,0,224,56H62.68L56.6,22.57A8,8,0,0,0,48.73,16H24a8,8,0,0,0,0,16h18L67.56,172.29a24,24,0,0,0,5.33,11.27,28,28,0,1,0,44.4,8.44h45.42A27.75,27.75,0,0,0,160,204a28,28,0,1,0,28-28H91.17a8,8,0,0,1-7.87-6.57L80.13,152h116a24,24,0,0,0,23.61-19.71l12.16-66.86A8,8,0,0,0,230.14,58.87ZM104,204a12,12,0,1,1-12-12A12,12,0,0,1,104,204Zm96,0a12,12,0,1,1-12-12A12,12,0,0,1,200,204Zm4-74.57A8,8,0,0,1,196.1,136H77.22L65.59,72H214.41Z"/></svg></button>
</div></div>
</li>
<li class="mv__row mv__row--saved is-out" data-pair="p2">
<div class="mv__box"><div class="mv__in">
<span class="mv__name">Linen apron</span>
<span class="mv__price">16,000 KRW</span>
<button class="mv__b mv__b--back" type="button" aria-label="Move Linen apron back to the cart"><svg viewBox="0 0 256 256" width="13" height="13" fill="currentColor" aria-hidden="true"><path d="M224,64l-12.16,66.86A16,16,0,0,1,196.1,144H70.55L56,64Z" opacity="0.2"/><path d="M230.14,58.87A8,8,0,0,0,224,56H62.68L56.6,22.57A8,8,0,0,0,48.73,16H24a8,8,0,0,0,0,16h18L67.56,172.29a24,24,0,0,0,5.33,11.27,28,28,0,1,0,44.4,8.44h45.42A27.75,27.75,0,0,0,160,204a28,28,0,1,0,28-28H91.17a8,8,0,0,1-7.87-6.57L80.13,152h116a24,24,0,0,0,23.61-19.71l12.16-66.86A8,8,0,0,0,230.14,58.87ZM104,204a12,12,0,1,1-12-12A12,12,0,0,1,104,204Zm96,0a12,12,0,1,1-12-12A12,12,0,0,1,200,204Zm4-74.57A8,8,0,0,1,196.1,136H77.22L65.59,72H214.41Z"/></svg></button>
</div></div>
</li>
</ul>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 나중에 담기를 누르면 그 줄이 grid-template-rows: 1fr → 0fr 로 접히고,
// 보관 묶음의 같은 상품 줄이 그만큼 열리며 안쪽 내용이 translateY 로 위에서 내려앉는다.
// 접히는 높이와 열리는 높이가 같아서 카드 전체 높이는 움직이지 않는다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-ink;
@include stage($stage-paper);
.mv {
box-sizing: border-box;
width: min(300px, 92vw);
padding: $sp-3;
border-radius: $r-card;
background: $subject-cream;
box-shadow: $shadow-raised;
color: $subject-ink;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.mv * { box-sizing: border-box; }
.mv__group + .mv__group { margin-top: $sp-2; }
.mv__head {
display: flex;
align-items: center;
gap: $sp-2;
margin: 0 0 $sp-1;
}
.mv__label {
font-size: 11px;
font-weight: 700;
line-height: 1.3;
}
.mv__n {
position: relative;
min-width: 18px;
padding: 0 $sp-1;
border-radius: $r-pill;
background: rgba(23, 20, 26, .1);
text-align: center;
font-size: 10px;
font-weight: 700;
line-height: 1.6;
font-variant-numeric: tabular-nums;
}
// 미리보기 루프에서만 쓰는 두 번째 숫자 — 자리를 차지하지 않고 정적 경로에서는 보이지 않는다
.mv__alt {
position: absolute;
inset: 0;
display: grid;
place-items: center;
opacity: 0;
}
.mv__list {
display: grid;
gap: $sp-1;
margin: 0;
padding: 0;
list-style: none;
}
// 줄 하나 = 높이를 주고받는 칸. 실제 클릭 경로는 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;
border-radius: $r-sm;
background: $stage-paper;
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, .12);
}
.mv__in {
display: grid;
grid-template-columns: 1fr auto auto;
align-items: center;
gap: $sp-2;
padding: $sp-1 $sp-2;
}
.mv__row--saved .mv__box {
background: $subject-ink;
box-shadow: none;
}
.mv__row--saved .mv__in { color: $subject-cream; }
.mv__name {
min-width: 0;
font-size: 11px;
font-weight: 700;
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mv__price {
font-size: 10px;
font-weight: 700;
line-height: 1.4;
font-variant-numeric: tabular-nums;
opacity: .72;
}
.mv__b {
display: inline-grid;
place-items: center;
width: 22px;
height: 22px;
padding: 0;
margin: 0;
border: 0;
border-radius: 50%;
background: $subject-ink;
color: $subject-cream;
cursor: pointer;
&:focus-visible { outline: 2px solid $subject-blue; outline-offset: 2px; }
}
.mv__b--back {
background: $subject-cream;
color: $subject-ink;
}
// ── 격자 미리보기 루프 (2초) ──
// 여닫기를 한 프레임에 컷으로 끝낸다 — 대표 이미지가 「반쯤 접힌 줄」 이 되지 않게.
// 돌아오는 쪽만 70~86% 에 천천히 풀어 퇴장 프레임이 등장 프레임을 이기지 못하게 한다.
@keyframes mv-leave {
0% { grid-template-rows: 1fr; animation-timing-function: steps(1, end); }
24% { grid-template-rows: 1fr; animation-timing-function: steps(1, end); }
25% { grid-template-rows: 0fr; animation-timing-function: steps(1, end); }
70% { grid-template-rows: 0fr; animation-timing-function: $ease-out; }
86% { grid-template-rows: 1fr; }
100% { grid-template-rows: 1fr; }
}
@keyframes mv-arrive {
0% { grid-template-rows: 0fr; animation-timing-function: steps(1, end); }
24% { grid-template-rows: 0fr; animation-timing-function: steps(1, end); }
25% { grid-template-rows: 1fr; animation-timing-function: steps(1, end); }
70% { grid-template-rows: 1fr; animation-timing-function: $ease-out; }
86% { grid-template-rows: 0fr; }
100% { grid-template-rows: 0fr; }
}
// 열린 칸 안쪽이 위에서 내려앉는다 — 투명도가 아니라 transform 이라 중간 프레임에 유령이 없다.
// 칸이 열리는 컷과 같은 순간에 글자가 이미 칸 안에 들어와 있어야 대표 이미지가 빈 띠가 되지 않는다
@keyframes mv-drop {
0%, 24% { transform: translateY(-10px); }
45%, 100% { transform: translateY(0); }
}
@keyframes mv-press {
0%, 21% { transform: scale(1); }
25% { transform: scale(.78); }
37%, 100% { transform: scale(1); }
}
@keyframes mv-a {
0%, 24% { opacity: 1; }
25%, 85% { opacity: 0; }
86%, 100% { opacity: 1; }
}
@keyframes mv-b {
0%, 24% { opacity: 0; }
25%, 85% { opacity: 1; }
86%, 100% { opacity: 0; }
}
.mv.is-demo {
#mv-cart .mv__row:nth-child(2) { animation-name: mv-leave; }
#mv-saved .mv__row:nth-child(2) { animation-name: mv-arrive; }
#mv-cart .mv__row:nth-child(2),
#mv-saved .mv__row:nth-child(2) {
animation-duration: $duration;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
#mv-saved .mv__row:nth-child(2) .mv__in {
animation-name: mv-drop;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
#mv-cart .mv__row:nth-child(2) .mv__b {
animation-name: mv-press;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.mv__swap .mv__v { animation-name: mv-a; }
.mv__swap .mv__alt { animation-name: mv-b; }
.mv__swap .mv__v,
.mv__swap .mv__alt {
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
}
@media (max-width: 340px) {
.mv { padding: $sp-2; }
.mv__in { padding: $sp-1; }
.mv__b { width: 20px; height: 20px; }
}
@media (prefers-reduced-motion: reduce) {
.mv.is-demo .mv__row,
.mv.is-demo .mv__in,
.mv.is-demo .mv__b,
.mv.is-demo .mv__v {
animation: none !important;
transform: none !important;
}
.mv.is-demo .mv__v { opacity: 1 !important; }
// 정지 상태에서도 두 목록이 무엇인지는 보인다 — 겹쳐 둔 두 번째 숫자만 계속 감춘다
.mv.is-demo .mv__alt { animation: none !important; opacity: 0 !important; }
.mv__row { transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.mv {
box-sizing: border-box;
width: min(300px, 92vw);
padding: 12px;
border-radius: 16px;
background: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
color: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.mv * {
box-sizing: border-box;
}
.mv__group + .mv__group {
margin-top: 8px;
}
.mv__head {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 4px;
}
.mv__label {
font-size: 11px;
font-weight: 700;
line-height: 1.3;
}
.mv__n {
position: relative;
min-width: 18px;
padding: 0 4px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.1);
text-align: center;
font-size: 10px;
font-weight: 700;
line-height: 1.6;
font-variant-numeric: tabular-nums;
}
.mv__alt {
position: absolute;
inset: 0;
display: grid;
place-items: center;
opacity: 0;
}
.mv__list {
display: grid;
gap: 4px;
margin: 0;
padding: 0;
list-style: none;
}
.mv__row {
display: grid;
grid-template-rows: 1fr;
transition: grid-template-rows 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.mv__row.is-out {
grid-template-rows: 0fr;
}
.mv__box {
min-height: 0;
overflow: hidden;
border-radius: 8px;
background: #ffffff;
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.12);
}
.mv__in {
display: grid;
grid-template-columns: 1fr auto auto;
align-items: center;
gap: 8px;
padding: 4px 8px;
}
.mv__row--saved .mv__box {
background: #17141a;
box-shadow: none;
}
.mv__row--saved .mv__in {
color: #fff7e6;
}
.mv__name {
min-width: 0;
font-size: 11px;
font-weight: 700;
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mv__price {
font-size: 10px;
font-weight: 700;
line-height: 1.4;
font-variant-numeric: tabular-nums;
opacity: 0.72;
}
.mv__b {
display: inline-grid;
place-items: center;
width: 22px;
height: 22px;
padding: 0;
margin: 0;
border: 0;
border-radius: 50%;
background: #17141a;
color: #fff7e6;
cursor: pointer;
}
.mv__b:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.mv__b--back {
background: #fff7e6;
color: #17141a;
}
@keyframes mv-leave {
0% {
grid-template-rows: 1fr;
animation-timing-function: steps(1, end);
}
24% {
grid-template-rows: 1fr;
animation-timing-function: steps(1, end);
}
25% {
grid-template-rows: 0fr;
animation-timing-function: steps(1, end);
}
70% {
grid-template-rows: 0fr;
animation-timing-function: ease-out;
}
86% {
grid-template-rows: 1fr;
}
100% {
grid-template-rows: 1fr;
}
}
@keyframes mv-arrive {
0% {
grid-template-rows: 0fr;
animation-timing-function: steps(1, end);
}
24% {
grid-template-rows: 0fr;
animation-timing-function: steps(1, end);
}
25% {
grid-template-rows: 1fr;
animation-timing-function: steps(1, end);
}
70% {
grid-template-rows: 1fr;
animation-timing-function: ease-out;
}
86% {
grid-template-rows: 0fr;
}
100% {
grid-template-rows: 0fr;
}
}
@keyframes mv-drop {
0%, 24% {
transform: translateY(-10px);
}
45%, 100% {
transform: translateY(0);
}
}
@keyframes mv-press {
0%, 21% {
transform: scale(1);
}
25% {
transform: scale(0.78);
}
37%, 100% {
transform: scale(1);
}
}
@keyframes mv-a {
0%, 24% {
opacity: 1;
}
25%, 85% {
opacity: 0;
}
86%, 100% {
opacity: 1;
}
}
@keyframes mv-b {
0%, 24% {
opacity: 0;
}
25%, 85% {
opacity: 1;
}
86%, 100% {
opacity: 0;
}
}
.mv.is-demo #mv-cart .mv__row:nth-child(2) {
animation-name: mv-leave;
}
.mv.is-demo #mv-saved .mv__row:nth-child(2) {
animation-name: mv-arrive;
}
.mv.is-demo #mv-cart .mv__row:nth-child(2),
.mv.is-demo #mv-saved .mv__row:nth-child(2) {
animation-duration: 2s;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.mv.is-demo #mv-saved .mv__row:nth-child(2) .mv__in {
animation-name: mv-drop;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.mv.is-demo #mv-cart .mv__row:nth-child(2) .mv__b {
animation-name: mv-press;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.mv.is-demo .mv__swap .mv__v {
animation-name: mv-a;
}
.mv.is-demo .mv__swap .mv__alt {
animation-name: mv-b;
}
.mv.is-demo .mv__swap .mv__v,
.mv.is-demo .mv__swap .mv__alt {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@media (max-width: 340px) {
.mv {
padding: 8px;
}
.mv__in {
padding: 4px;
}
.mv__b {
width: 20px;
height: 20px;
}
}
@media (prefers-reduced-motion: reduce) {
.mv.is-demo .mv__row,
.mv.is-demo .mv__in,
.mv.is-demo .mv__b,
.mv.is-demo .mv__v {
animation: none !important;
transform: none !important;
}
.mv.is-demo .mv__v {
opacity: 1 !important;
}
.mv.is-demo .mv__alt {
animation: none !important;
opacity: 0 !important;
}
.mv__row {
transition: none !important;
}
}// 같은 상품의 줄을 두 목록에 미리 한 벌씩 두고 is-out 으로 높이(1fr↔0fr)만 주고받는다.
// 옮기는 쪽이 접히는 만큼 받는 쪽이 열리므로 Card 전체 높이는 그대로다
var mv = document.getElementById('mv');
var mvCart = document.getElementById('mv-cart');
var mvSaved = document.getElementById('mv-saved');
function mvTwin(list, pair) { return list.querySelector('[data-pair="' + pair + '"]'); }
function mvCount() {
var lists = [mvCart, mvSaved];
for (var i = 0; i < lists.length; i++) {
var open = lists[i].querySelectorAll('.mv__row:not(.is-out)').length;
lists[i].parentNode.querySelector('.mv__n .mv__v').textContent = String(open);
}
}
function mvMove(row, to) {
mv.classList.remove('is-demo');
row.classList.add('is-out');
mvTwin(to, row.getAttribute('data-pair')).classList.remove('is-out');
mvCount();
}
mv.addEventListener('click', function (e) {
var btn = e.target.closest('.mv__b');
if (!btn) { return; }
var row = btn.closest('.mv__row');
mvMove(row, mvCart.contains(row) ? mvSaved : mvCart);
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./CartMoveToSaved.module.scss";
export type CartSavedItem = {
id: string;
name: string;
price: string;
moveLabel: string;
backLabel: string;
};
export type CartMoveToSavedProps = {
cartLabel?: string;
savedLabel?: string;
groupLabel?: string;
items?: CartSavedItem[];
/** 격자 미리보기용 2초 자동 루프. 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const DEFAULT_ITEMS: CartSavedItem[] = [
{ id: "p1", name: "라탄 바구니", price: "24,000원", moveLabel: "라탄 바구니를 나중에 담기로 옮기기", backLabel: "라탄 바구니를 장바구니로 되돌리기" },
{ id: "p2", name: "린넨 앞치마", price: "16,000원", moveLabel: "린넨 앞치마를 나중에 담기로 옮기기", backLabel: "린넨 앞치마를 장바구니로 되돌리기" },
];
function BookmarkIcon() {
return (
<svg viewBox="0 0 256 256" width="13" height="13" fill="currentColor" aria-hidden="true"><path d="M192,48V224l-64-40L64,224V48a8,8,0,0,1,8-8H184A8,8,0,0,1,192,48Z" opacity="0.2"/><path d="M184,32H72A16,16,0,0,0,56,48V224a8,8,0,0,0,12.24,6.78L128,193.43l59.77,37.35A8,8,0,0,0,200,224V48A16,16,0,0,0,184,32Zm0,177.57-51.77-32.35a8,8,0,0,0-8.48,0L72,209.57V48H184Z"/></svg>
);
}
function CartIcon() {
return (
<svg viewBox="0 0 256 256" width="13" height="13" fill="currentColor" aria-hidden="true"><path d="M224,64l-12.16,66.86A16,16,0,0,1,196.1,144H70.55L56,64Z" opacity="0.2"/><path d="M230.14,58.87A8,8,0,0,0,224,56H62.68L56.6,22.57A8,8,0,0,0,48.73,16H24a8,8,0,0,0,0,16h18L67.56,172.29a24,24,0,0,0,5.33,11.27,28,28,0,1,0,44.4,8.44h45.42A27.75,27.75,0,0,0,160,204a28,28,0,1,0,28-28H91.17a8,8,0,0,1-7.87-6.57L80.13,152h116a24,24,0,0,0,23.61-19.71l12.16-66.86A8,8,0,0,0,230.14,58.87ZM104,204a12,12,0,1,1-12-12A12,12,0,0,1,104,204Zm96,0a12,12,0,1,1-12-12A12,12,0,0,1,200,204Zm4-74.57A8,8,0,0,1,196.1,136H77.22L65.59,72H214.41Z"/></svg>
);
}
export default function CartMoveToSaved({
cartLabel = "장바구니",
savedLabel = "나중에 담기",
groupLabel = "장바구니와 나중에 담기",
items = DEFAULT_ITEMS,
demo = true,
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#17141a",
}: CartMoveToSavedProps) {
const [savedIds, setSavedIds] = useState<string[]>([]);
const [live, setLive] = useState(demo);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 같은 상품 줄을 두 목록에 한 벌씩 두고 높이(1fr↔0fr)만 주고받는다 — 카드 전체 높이는 그대로다
function move(id: string, toSaved: boolean) {
setLive(false);
setSavedIds((prev: string[]) => (toSaved ? prev.concat([id]) : prev.filter((x) => x !== id)));
}
const cartCount = items.length - savedIds.length;
const savedCount = savedIds.length;
// 미리보기 루프가 컷으로 갈아 끼우는 두 번째 숫자 — 둘째 줄이 옮겨 갔을 때의 개수
const altCart = items.length - 1;
const altSaved = 1;
function count(now: number, alt: number) {
return (
<output className={`${styles.n} ${styles.swap}`}>
<span className={styles.value}>{now}</span>
{live ? <span className={styles.alt} aria-hidden="true">{alt}</span> : null}
</output>
);
}
function row(item: CartSavedItem, saved: boolean) {
const out = saved ? savedIds.indexOf(item.id) < 0 : savedIds.indexOf(item.id) >= 0;
const cls = [styles.row, saved ? styles.rowSaved : "", out ? styles.isOut : ""].filter(Boolean).join(" ");
return (
<li className={cls} key={item.id}>
<div className={styles.box}>
<div className={styles.in}>
<span className={styles.name}>{item.name}</span>
<span className={styles.price}>{item.price}</span>
<button
className={`${styles.btn} ${saved ? styles.btnBack : ""}`}
type="button"
onClick={() => move(item.id, !saved)}
aria-label={saved ? item.backLabel : item.moveLabel}
>
{saved ? <CartIcon /> : <BookmarkIcon />}
</button>
</div>
</div>
</li>
);
}
const cls = [styles.mv, live ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<section className={cls} style={vars} aria-label={groupLabel}>
<div className={styles.group}>
<p className={styles.head}>
<span className={styles.label}>{cartLabel}</span>
{count(cartCount, altCart)}
</p>
<ul className={`${styles.list} ${styles.listCart}`}>{items.map((it) => row(it, false))}</ul>
</div>
<div className={styles.group}>
<p className={styles.head}>
<span className={styles.label}>{savedLabel}</span>
{count(savedCount, altSaved)}
</p>
<ul className={`${styles.list} ${styles.listSaved}`}>{items.map((it) => row(it, true))}</ul>
</div>
</section>
);
}02 · Line item that slides down into the saved-for-later group — Measured area 11.938% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
Use it for save-for-later lists or moving an item to a wishlist.
// One row = a cell that trades its height. The real click path folds via transition
.mv__row {
display: grid;
grid-template-rows: 1fr;
transition: grid-template-rows $dur-base $easing;
}
.mv__row.is-out { grid-template-rows: 0fr; }
// The folding cell carries no padding — vertical padding survives 0fr.
// This inner wrapper reaches height 0 and clips the padded real row inside
.mv__box {
min-height: 0;
overflow: hidden;
}
03Discount line that wedges into the order summary
What moves
Apply a coupon code, and a discount row unfolds from zero height between the item total and the grand total, pushing the rows below it down. The result sentence is announced from an aria-live region, the grand total recalculates in the same pass, and the render logged 15.799% changed area at intensity 110.8.

<main class="stage">
<section class="cp is-demo" id="cp" aria-label="Coupon code and order summary">
<p class="cp__title">Order summary</p>
<div class="cp__sum">
<p class="cp__line">
<span class="cp__k">Item total</span>
<span class="cp__v" id="cp-items">32,000 KRW</span>
</p>
<div class="cp__disc">
<div class="cp__box">
<p class="cp__line cp__line--disc">
<span class="cp__k"><svg viewBox="0 0 256 256" width="12" height="12" fill="currentColor" aria-hidden="true"><path d="M200,128a32,32,0,0,0,32,32v32a8,8,0,0,1-8,8H96V56H224a8,8,0,0,1,8,8V96A32,32,0,0,0,200,128Z" opacity="0.2"/><path d="M232,104a8,8,0,0,0,8-8V64a16,16,0,0,0-16-16H32A16,16,0,0,0,16,64V96a8,8,0,0,0,8,8,24,24,0,0,1,0,48,8,8,0,0,0-8,8v32a16,16,0,0,0,16,16H224a16,16,0,0,0,16-16V160a8,8,0,0,0-8-8,24,24,0,0,1,0-48ZM32,167.2a40,40,0,0,0,0-78.4V64H88V192H32Zm192,0V192H104V64H224V88.8a40,40,0,0,0,0,78.4Z"/></svg>Coupon discount</span>
<span class="cp__v cp__v--off" id="cp-off">-3,000 KRW</span>
</p>
</div>
</div>
<p class="cp__line cp__line--total">
<span class="cp__k">Total</span>
<span class="cp__v cp__v--total cp__swap"><span class="cp__t" id="cp-totalv">32,000 KRW</span><span class="cp__alt" aria-hidden="true">29,000 KRW</span></span>
</p>
</div>
<form class="cp__form" id="cp-form">
<span class="cp__field">
<input class="cp__code" id="cp-code" type="text" placeholder="Coupon code" aria-label="Coupon code" autocomplete="off">
<span class="cp__ghost" aria-hidden="true">SAVE3000</span>
</span>
<button class="cp__go" type="submit">Apply</button>
</form>
<p class="cp__msg" aria-live="polite"><span class="cp__msg-t" id="cp-msg"></span><span class="cp__msg-d" aria-hidden="true">Coupon applied</span></p>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 쿠폰을 적용하면 상품 금액과 합계 사이의 칸이 grid-template-rows: 0fr → 1fr 로 펴지며
// 할인 줄이 끼어들고 아래 줄이 밀려 내려간다. 결과 문구는 aria-live 영역으로 읽힌다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $stage-yellow;
@include stage($stage-yellow);
.cp {
box-sizing: border-box;
width: min(300px, 92vw);
padding: $sp-3;
border-radius: $r-card;
background: $subject-ink;
color: $subject-cream;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.cp * { box-sizing: border-box; }
.cp__title {
margin: 0 0 $sp-1;
font-size: 12px;
font-weight: 700;
line-height: 1.3;
}
.cp__sum { display: grid; }
.cp__line {
display: flex;
align-items: center;
justify-content: space-between;
gap: $sp-2;
margin: 0;
padding: $sp-1 0;
font-size: 11px;
line-height: 1.4;
}
.cp__k {
display: inline-flex;
align-items: center;
gap: $sp-1;
font-weight: 500;
opacity: .74;
}
.cp__v {
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.cp__v--off { color: $color; }
.cp__line--disc .cp__k {
color: $color;
opacity: 1;
}
.cp__line--total {
margin-top: $sp-1;
padding-top: $sp-2;
border-top: 1px solid rgba($subject-cream, .22);
font-size: 12px;
}
.cp__v--total {
position: relative;
font-size: 13px;
}
// 미리보기 루프에서만 쓰는 두 번째 숫자 — 자리를 차지하지 않고 정적 경로에서는 보이지 않는다
.cp__alt {
position: absolute;
inset: 0;
display: grid;
place-items: center end;
opacity: 0;
}
// 끼어드는 할인 줄. 실제 클릭 경로는 transition 으로 부드럽게 펴진다
.cp__disc {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows $dur-base $easing;
}
.cp.is-on .cp__disc { grid-template-rows: 1fr; }
// 접히는 칸에는 패딩을 두지 않는다 — 0fr 이 되어도 세로 패딩은 그대로 남는다
.cp__box {
min-height: 0;
overflow: hidden;
}
.cp__form {
display: grid;
grid-template-columns: 1fr auto;
gap: $sp-2;
margin-top: $sp-2;
}
.cp__field {
position: relative;
display: grid;
}
.cp__code {
width: 100%;
padding: $sp-1 $sp-2;
border: 1px solid rgba($subject-cream, .28);
border-radius: $r-sm;
background: rgba($subject-cream, .1);
color: $subject-cream;
font-family: inherit;
font-size: 11px;
font-weight: 700;
line-height: 1.5;
letter-spacing: .04em;
&::placeholder {
color: rgba($subject-cream, .5);
font-weight: 500;
letter-spacing: 0;
}
&:focus-visible { outline: 2px solid $color; outline-offset: 1px; }
}
// 미리보기에서만 입력칸 위에 얹는 코드 글자 — 정적 경로에서는 색만 지워 자리를 남긴다
.cp__ghost {
position: absolute;
inset: 0;
display: grid;
align-items: center;
padding-left: $sp-2;
color: transparent;
font-size: 11px;
font-weight: 700;
line-height: 1.5;
letter-spacing: .04em;
pointer-events: none;
}
.cp__go {
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-sm;
background: $color;
color: $subject-ink;
font-family: inherit;
font-size: 11px;
font-weight: 700;
line-height: 1.5;
cursor: pointer;
&:focus-visible { outline: 2px solid $subject-cream; outline-offset: 2px; }
}
.cp__msg {
position: relative;
min-height: 14px;
margin: $sp-1 0 0;
font-size: 10px;
font-weight: 500;
line-height: 1.4;
}
.cp__msg-t {
opacity: 0;
transition: opacity $dur-quick $ease-out;
}
.cp.is-said .cp__msg-t { opacity: 1; }
.cp__msg-d {
position: absolute;
inset: 0;
opacity: 0;
}
// ── 격자 미리보기 루프 (2초) ──
// 여는 순간을 한 프레임에 컷으로 끝낸다 — 대표 이미지가 「반쯤 펴진 줄」 이 되지 않게.
// 닫는 쪽만 70~86% 에 천천히 풀어 퇴장 프레임이 등장 프레임을 이기지 못하게 한다
@keyframes cp-open {
0% { grid-template-rows: 0fr; animation-timing-function: steps(1, end); }
24% { grid-template-rows: 0fr; animation-timing-function: steps(1, end); }
25% { grid-template-rows: 1fr; animation-timing-function: steps(1, end); }
70% { grid-template-rows: 1fr; animation-timing-function: $ease-out; }
86% { grid-template-rows: 0fr; }
100% { grid-template-rows: 0fr; }
}
// 끼어든 줄이 제자리에 내려앉는다 — transform 이라 중간 프레임에 유령이 없다
@keyframes cp-settle {
0%, 24% { transform: translateY(-8px); }
45%, 100% { transform: translateY(0); }
}
@keyframes cp-ghost {
0%, 11% { opacity: 0; }
12%, 85% { opacity: 1; }
86%, 100% { opacity: 0; }
}
@keyframes cp-press {
0%, 20% { transform: scale(1); }
25% { transform: scale(.88); }
37%, 100% { transform: scale(1); }
}
@keyframes cp-a {
0%, 24% { opacity: 1; }
25%, 85% { opacity: 0; }
86%, 100% { opacity: 1; }
}
@keyframes cp-b {
0%, 24% { opacity: 0; }
25%, 85% { opacity: 1; }
86%, 100% { opacity: 0; }
}
.cp.is-demo {
.cp__code::placeholder { color: transparent; }
.cp__ghost {
color: $subject-cream;
animation-name: cp-ghost;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.cp__disc {
animation-name: cp-open;
animation-duration: $duration;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.cp__line--disc {
animation-name: cp-settle;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.cp__go {
animation-name: cp-press;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.cp__swap .cp__t { animation-name: cp-a; }
.cp__swap .cp__alt,
.cp__msg-d { animation-name: cp-b; }
.cp__swap .cp__t,
.cp__swap .cp__alt,
.cp__msg-d {
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
}
@media (max-width: 340px) {
.cp { padding: $sp-2; }
.cp__line { padding: 0; }
.cp__line--total { padding-top: $sp-1; }
.cp__form { gap: $sp-1; }
.cp__go { padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.cp.is-demo .cp__disc,
.cp.is-demo .cp__line--disc,
.cp.is-demo .cp__go,
.cp.is-demo .cp__t {
animation: none !important;
transform: none !important;
}
.cp.is-demo .cp__t { opacity: 1 !important; }
// 정지 상태에서도 요약이 무엇인지는 보인다 — 겹쳐 둔 두 번째 값과 안내만 계속 감춘다
.cp.is-demo .cp__alt,
.cp.is-demo .cp__msg-d { animation: none !important; opacity: 0 !important; }
// 움직임을 뺀 자리는 「쿠폰을 넣기 전」 한 장면으로 고정한다 — 할인 줄은 닫힌 채,
// 코드 글자도 감추고 placeholder 를 되살려 요약과 합계가 서로 어긋나지 않게
.cp.is-demo .cp__ghost { animation: none !important; opacity: 0 !important; }
.cp.is-demo .cp__code::placeholder { color: rgba($subject-cream, .5) !important; }
.cp__disc { transition: none !important; }
.cp__msg-t { transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cp {
box-sizing: border-box;
width: min(300px, 92vw);
padding: 12px;
border-radius: 16px;
background: #17141a;
color: #fff7e6;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.cp * {
box-sizing: border-box;
}
.cp__title {
margin: 0 0 4px;
font-size: 12px;
font-weight: 700;
line-height: 1.3;
}
.cp__sum {
display: grid;
}
.cp__line {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin: 0;
padding: 4px 0;
font-size: 11px;
line-height: 1.4;
}
.cp__k {
display: inline-flex;
align-items: center;
gap: 4px;
font-weight: 500;
opacity: 0.74;
}
.cp__v {
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.cp__v--off {
color: #ffd23f;
}
.cp__line--disc .cp__k {
color: #ffd23f;
opacity: 1;
}
.cp__line--total {
margin-top: 4px;
padding-top: 8px;
border-top: 1px solid rgba(255, 247, 230, 0.22);
font-size: 12px;
}
.cp__v--total {
position: relative;
font-size: 13px;
}
.cp__alt {
position: absolute;
inset: 0;
display: grid;
place-items: center end;
opacity: 0;
}
.cp__disc {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.cp.is-on .cp__disc {
grid-template-rows: 1fr;
}
.cp__box {
min-height: 0;
overflow: hidden;
}
.cp__form {
display: grid;
grid-template-columns: 1fr auto;
gap: 8px;
margin-top: 8px;
}
.cp__field {
position: relative;
display: grid;
}
.cp__code {
width: 100%;
padding: 4px 8px;
border: 1px solid rgba(255, 247, 230, 0.28);
border-radius: 8px;
background: rgba(255, 247, 230, 0.1);
color: #fff7e6;
font-family: inherit;
font-size: 11px;
font-weight: 700;
line-height: 1.5;
letter-spacing: 0.04em;
}
.cp__code::placeholder {
color: rgba(255, 247, 230, 0.5);
font-weight: 500;
letter-spacing: 0;
}
.cp__code:focus-visible {
outline: 2px solid #ffd23f;
outline-offset: 1px;
}
.cp__ghost {
position: absolute;
inset: 0;
display: grid;
align-items: center;
padding-left: 8px;
color: transparent;
font-size: 11px;
font-weight: 700;
line-height: 1.5;
letter-spacing: 0.04em;
pointer-events: none;
}
.cp__go {
padding: 4px 12px;
border: 0;
border-radius: 8px;
background: #ffd23f;
color: #17141a;
font-family: inherit;
font-size: 11px;
font-weight: 700;
line-height: 1.5;
cursor: pointer;
}
.cp__go:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.cp__msg {
position: relative;
min-height: 14px;
margin: 4px 0 0;
font-size: 10px;
font-weight: 500;
line-height: 1.4;
}
.cp__msg-t {
opacity: 0;
transition: opacity 220ms ease-out;
}
.cp.is-said .cp__msg-t {
opacity: 1;
}
.cp__msg-d {
position: absolute;
inset: 0;
opacity: 0;
}
@keyframes cp-open {
0% {
grid-template-rows: 0fr;
animation-timing-function: steps(1, end);
}
24% {
grid-template-rows: 0fr;
animation-timing-function: steps(1, end);
}
25% {
grid-template-rows: 1fr;
animation-timing-function: steps(1, end);
}
70% {
grid-template-rows: 1fr;
animation-timing-function: ease-out;
}
86% {
grid-template-rows: 0fr;
}
100% {
grid-template-rows: 0fr;
}
}
@keyframes cp-settle {
0%, 24% {
transform: translateY(-8px);
}
45%, 100% {
transform: translateY(0);
}
}
@keyframes cp-ghost {
0%, 11% {
opacity: 0;
}
12%, 85% {
opacity: 1;
}
86%, 100% {
opacity: 0;
}
}
@keyframes cp-press {
0%, 20% {
transform: scale(1);
}
25% {
transform: scale(0.88);
}
37%, 100% {
transform: scale(1);
}
}
@keyframes cp-a {
0%, 24% {
opacity: 1;
}
25%, 85% {
opacity: 0;
}
86%, 100% {
opacity: 1;
}
}
@keyframes cp-b {
0%, 24% {
opacity: 0;
}
25%, 85% {
opacity: 1;
}
86%, 100% {
opacity: 0;
}
}
.cp.is-demo .cp__code::placeholder {
color: transparent;
}
.cp.is-demo .cp__ghost {
color: #fff7e6;
animation-name: cp-ghost;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.cp.is-demo .cp__disc {
animation-name: cp-open;
animation-duration: 2s;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.cp.is-demo .cp__line--disc {
animation-name: cp-settle;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.cp.is-demo .cp__go {
animation-name: cp-press;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.cp.is-demo .cp__swap .cp__t {
animation-name: cp-a;
}
.cp.is-demo .cp__swap .cp__alt,
.cp.is-demo .cp__msg-d {
animation-name: cp-b;
}
.cp.is-demo .cp__swap .cp__t,
.cp.is-demo .cp__swap .cp__alt,
.cp.is-demo .cp__msg-d {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@media (max-width: 340px) {
.cp {
padding: 8px;
}
.cp__line {
padding: 0;
}
.cp__line--total {
padding-top: 4px;
}
.cp__form {
gap: 4px;
}
.cp__go {
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.cp.is-demo .cp__disc,
.cp.is-demo .cp__line--disc,
.cp.is-demo .cp__go,
.cp.is-demo .cp__t {
animation: none !important;
transform: none !important;
}
.cp.is-demo .cp__t {
opacity: 1 !important;
}
.cp.is-demo .cp__alt,
.cp.is-demo .cp__msg-d {
animation: none !important;
opacity: 0 !important;
}
.cp.is-demo .cp__ghost {
animation: none !important;
opacity: 0 !important;
}
.cp.is-demo .cp__code::placeholder {
color: rgba(255, 247, 230, 0.5) !important;
}
.cp__disc {
transition: none !important;
}
.cp__msg-t {
transition: none !important;
}
}// 적용을 누르면 상품 금액과 Total 사이의 칸이 0fr 에서 1fr 로 펴지며 할인 줄이 끼어들고,
// Total 숫자가 다시 계산된다. 금액 서식은 화면의 Item total 글자에서 뽑아 언어팩을 따라간다
var cp = document.getElementById('cp');
var cpForm = document.getElementById('cp-form');
var cpCode = document.getElementById('cp-code');
var cpMsg = document.getElementById('cp-msg');
var cpItems = document.getElementById('cp-items');
var cpOff = document.getElementById('cp-off');
var cpTotalValue = document.getElementById('cp-totalv');
var cpValidCode = 'SAVE3000';
var cpOkText = 'Coupon applied';
var cpEmptyText = 'Enter a coupon code';
var cpWrongText = 'That code does not exist';
function cpComma(n) { return String(n).replace(/\B(?=(\d{3})+$)/g, ','); }
function cpAmount(text) { return Number(text.replace(/[^\d]/g, '')); }
function cpMoney(n) { return cpItems.textContent.replace(/[\d,]+/, cpComma(n)); }
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);
cp.classList.add('is-said');
}
cpForm.addEventListener('submit', function (e) { e.preventDefault(); cpApply(); });import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./CartCouponDiscountLine.module.scss";
export type CartCouponDiscountLineProps = {
title?: string;
itemsLabel?: string;
/** 상품 금액 글자 그대로 — 금액 서식(통화 기호·자릿점)을 여기서 뽑아 쓴다 */
itemsValue?: string;
discountLabel?: string;
discountValue?: string;
totalLabel?: string;
codeLabel?: string;
applyLabel?: string;
groupLabel?: string;
validCode?: string;
okText?: string;
emptyText?: string;
wrongText?: string;
/** 격자 미리보기용 2초 자동 루프. 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// ambient 가 얇아 입력 이벤트 제네릭을 못 쓴다 — 필요한 꼴만 로컬 타입으로 선언
type ChangeEvt = { target: { value: string } };
type SubmitEvt = { preventDefault: () => void };
function comma(n: number): string {
return String(n).replace(/\B(?=(\d{3})+$)/g, ",");
}
function amount(text: string): number {
return Number(text.replace(/[^\d]/g, ""));
}
// 금액 서식은 상품 금액 글자를 그대로 본떠 만든다 — 언어팩이 바꿔 둔 통화 표기를 따라간다
function money(source: string, n: number): string {
return source.replace(/[\d,]+/, comma(n));
}
function TicketIcon() {
return (
<svg viewBox="0 0 256 256" width="12" height="12" fill="currentColor" aria-hidden="true"><path d="M200,128a32,32,0,0,0,32,32v32a8,8,0,0,1-8,8H96V56H224a8,8,0,0,1,8,8V96A32,32,0,0,0,200,128Z" opacity="0.2"/><path d="M232,104a8,8,0,0,0,8-8V64a16,16,0,0,0-16-16H32A16,16,0,0,0,16,64V96a8,8,0,0,0,8,8,24,24,0,0,1,0,48,8,8,0,0,0-8,8v32a16,16,0,0,0,16,16H224a16,16,0,0,0,16-16V160a8,8,0,0,0-8-8,24,24,0,0,1,0-48ZM32,167.2a40,40,0,0,0,0-78.4V64H88V192H32Zm192,0V192H104V64H224V88.8a40,40,0,0,0,0,78.4Z"/></svg>
);
}
export default function CartCouponDiscountLine({
title = "주문 요약",
itemsLabel = "상품 금액",
itemsValue = "32,000원",
discountLabel = "쿠폰 할인",
discountValue = "-3,000원",
totalLabel = "합계",
codeLabel = "쿠폰 코드",
applyLabel = "적용",
groupLabel = "쿠폰 코드와 주문 요약",
validCode = "SAVE3000",
okText = "쿠폰이 적용됐어요",
emptyText = "쿠폰 코드를 입력해 주세요",
wrongText = "없는 코드예요",
demo = true,
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#ffd23f",
}: CartCouponDiscountLineProps) {
const [code, setCode] = useState("");
const [on, setOn] = useState(false);
const [message, setMessage] = useState("");
const [live, setLive] = useState(demo);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const items = amount(itemsValue);
const off = amount(discountValue);
const total = money(itemsValue, on ? items - off : items);
// 미리보기 루프가 컷으로 갈아 끼우는 두 번째 값 — 할인이 붙었을 때의 합계
const altTotal = money(itemsValue, items - off);
// 적용을 누르면 할인 줄이 0fr 에서 1fr 로 펴지고 합계가 같은 순간에 다시 계산된다
function apply(e: SubmitEvt) {
e.preventDefault();
setLive(false);
const typed = code.trim().toUpperCase();
const hit = typed === validCode;
setOn(hit);
setMessage(hit ? okText : typed ? wrongText : emptyText);
}
const cls = [styles.cp, live ? styles.isDemo : "", on ? styles.isOn : "", message ? styles.isSaid : ""].filter(Boolean).join(" ");
return (
<section className={cls} style={vars} aria-label={groupLabel}>
<p className={styles.title}>{title}</p>
<div className={styles.sum}>
<p className={styles.line}>
<span className={styles.k}>{itemsLabel}</span>
<span className={styles.v}>{itemsValue}</span>
</p>
<div className={styles.disc}>
<div className={styles.box}>
<p className={`${styles.line} ${styles.lineDisc}`}>
<span className={styles.k}><TicketIcon />{discountLabel}</span>
<span className={`${styles.v} ${styles.vOff}`}>{discountValue}</span>
</p>
</div>
</div>
<p className={`${styles.line} ${styles.lineTotal}`}>
<span className={styles.k}>{totalLabel}</span>
<span className={`${styles.v} ${styles.vTotal} ${styles.swap}`}>
<span className={styles.t}>{total}</span>
{live ? <span className={styles.alt} aria-hidden="true">{altTotal}</span> : null}
</span>
</p>
</div>
<form className={styles.form} onSubmit={apply}>
<span className={styles.field}>
<input
className={styles.code}
type="text"
value={code}
onChange={(e: ChangeEvt) => setCode(e.target.value)}
placeholder={codeLabel}
aria-label={codeLabel}
autoComplete="off"
/>
{live ? <span className={styles.ghost} aria-hidden="true">{validCode}</span> : null}
</span>
<button className={styles.go} type="submit">{applyLabel}</button>
</form>
<p className={styles.msg} aria-live="polite">
<span className={styles.msgText}>{message}</span>
{live ? <span className={styles.msgDemo} aria-hidden="true">{okText}</span> : null}
</p>
</section>
);
}03 · Discount line that wedges into the order summary — Measured area 15.799% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
Use it on any order summary with a coupon or promo code field.
.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);
}
04Quantity that springs back when it passes the stock limit
What you see
Type more than the remaining stock, and the number bounces back to the maximum once while a warning strip drops over the row to say how many are left. The bounce is a translateY on a cubic-bezier overshoot, the strip carries role="alert" so screen readers hear the same message, and a reserved 24px slot above the row keeps the strip from covering the product name.

<main class="stage">
<section class="cs is-demo" id="cs" aria-label="Quantity capped by stock">
<p class="cs__kicker">Cart</p>
<div class="cs__band">
<!-- 경고 띠: 줄 위로 내려와 남은 개수를 말한다. role="alert" 라서 읽어주는 기계도 바로 말한다 -->
<p class="cs__strip" id="csStrip" role="alert">
<svg viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d="M215.46,216H40.54C27.92,216,20,202.79,26.13,192.09L113.59,40.22c6.3-11,22.52-11,28.82,0l87.46,151.87C236,202.79,228.08,216,215.46,216Z" opacity="0.2"/><path d="M236.8,188.09,149.35,36.22h0a24.76,24.76,0,0,0-42.7,0L19.2,188.09a23.51,23.51,0,0,0,0,23.72A24.35,24.35,0,0,0,40.55,224h174.9a24.35,24.35,0,0,0,21.33-12.19A23.51,23.51,0,0,0,236.8,188.09ZM222.93,203.8a8.5,8.5,0,0,1-7.48,4.2H40.55a8.5,8.5,0,0,1-7.48-4.2,7.59,7.59,0,0,1,0-7.72L120.52,44.21a8.75,8.75,0,0,1,15,0l87.45,151.87A7.59,7.59,0,0,1,222.93,203.8ZM120,144V104a8,8,0,0,1,16,0v40a8,8,0,0,1-16,0Zm20,36a12,12,0,1,1-12-12A12,12,0,0,1,140,180Z"/></svg>
<span>Only 4 left</span>
</p>
<div class="cs__row">
<span class="cs__thumb" aria-hidden="true"></span>
<span class="cs__info">
<b class="cs__name">Winter knit, ivory</b>
<span class="cs__price">64,000 KRW</span>
</span>
</div>
</div>
<div class="cs__qty">
<span class="cs__left">4 in stock</span>
<span class="cs__step">
<button class="cs__btn" type="button" id="csMinus" aria-label="Decrease quantity">−</button>
<span class="cs__num" id="csNum">
<input class="cs__input" id="csInput" type="number" inputmode="numeric" value="4" min="1" max="4" aria-label="Quantity">
<!-- 미리보기 전용: 재고를 넘겨 넣은 값. 손으로 쓰는 정적 상태에서는 투명하게 비켜 있고 자리만 지킨다 -->
<b class="cs__over" id="csOver" aria-hidden="true">6</b>
</span>
<button class="cs__btn" type="button" id="csPlus" aria-label="Increase quantity">+</button>
</span>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 재고 한도에 걸린 수량 — 남은 재고보다 큰 수는 최대치로 한 번 되튕기고, 줄 위로 경고 띠가 내려온다.
// $easing 은 오버슛이 있는 이징($ease-pop) — 되튕김이 제자리를 한 번 지나쳤다 돌아온다
$duration: $dur-loop;
$easing: $ease-pop;
$color: $error;
@include stage($stage-orange);
.cs {
box-sizing: border-box;
width: min(300px, 92vw);
padding: $sp-3;
border-radius: $r-card;
background: $subject-cream;
color: $subject-ink;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.cs * { box-sizing: border-box; }
.cs__kicker {
margin: 0 0 $sp-2;
font-size: 11px;
font-weight: 700;
line-height: 1.3;
letter-spacing: .04em;
color: rgba($subject-ink, .72);
}
// 띠가 물러난 자리를 잘라 두는 상자 — 띠는 이 상자 위쪽 밖에 숨어 있다.
// 위쪽 24px 은 띠가 내려와 설 자리다. 상품 줄을 덮어 버리면 무엇을 담았는지가 대표 이미지에서 사라진다
.cs__band {
position: relative;
overflow: hidden;
padding-top: $sp-6;
border-radius: $r-sm;
}
.cs__row {
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-2;
border-radius: $r-sm;
background: rgba($subject-ink, .05);
}
.cs__thumb {
flex: 0 0 auto;
width: 40px;
height: 40px;
border-radius: $r-xs;
background: rgba($subject-ink, .16);
}
.cs__info {
display: grid;
gap: $sp-1;
min-width: 0;
}
.cs__name {
font-size: 12px;
font-weight: 700;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cs__price {
font-size: 11px;
line-height: 1.2;
color: rgba($subject-ink, .62);
}
.cs__strip {
position: absolute;
left: 0;
right: 0;
top: 0;
display: flex;
align-items: center;
gap: $sp-2;
height: 24px;
margin: 0;
padding: 0 $sp-2;
border-radius: $r-xs;
background: $color;
color: $subject-cream;
font-size: 11px;
font-weight: 700;
line-height: 1.3;
// 정적(실제 사용) 경로 — JS 가 .is-over 를 켜면 내려오고, 잠시 뒤 스스로 올라간다
transform: translateY(-110%);
transition: transform $dur-quick $easing;
}
.cs.is-over .cs__strip { transform: translateY(0); }
.cs__strip svg { flex: 0 0 auto; width: 16px; height: 16px; }
.cs__qty {
display: flex;
align-items: center;
justify-content: space-between;
gap: $sp-2;
margin-top: $sp-3;
}
.cs__left {
font-size: 11px;
line-height: 1.3;
color: rgba($subject-ink, .62);
}
.cs__step {
position: relative;
display: flex;
align-items: center;
border: 1px solid rgba($subject-ink, .2);
border-radius: $r-control;
background: $stage-paper;
}
// 한도 초과 테두리 — 모양은 그대로 두고 opacity 만 켠다 (레이아웃을 다시 계산하지 않는다)
.cs__step::after {
content: "";
position: absolute;
inset: -1px;
border: 2px solid $color;
border-radius: $r-control;
opacity: 0;
pointer-events: none;
transition: opacity $dur-instant linear;
}
.cs.is-over .cs__step::after { opacity: 1; }
.cs__btn {
width: 28px;
height: 28px;
padding: 0;
border: 0;
border-radius: $r-control;
background: transparent;
color: $subject-ink;
font-size: 16px;
font-weight: 700;
line-height: 1;
cursor: pointer;
}
.cs__btn:focus-visible { outline: 2px solid $subject-blue; outline-offset: 2px; }
.cs__num {
position: relative;
display: grid;
place-items: center;
width: 44px;
height: 28px;
}
.cs__input {
width: 100%;
height: 100%;
padding: 0;
border: 0;
background: transparent;
color: $subject-ink;
font-family: inherit;
font-size: 14px;
font-weight: 700;
line-height: 1;
text-align: center;
appearance: textfield;
}
.cs__input::-webkit-outer-spin-button,
.cs__input::-webkit-inner-spin-button { appearance: none; margin: 0; }
.cs__input:focus-visible { outline: 2px solid $subject-blue; outline-offset: 2px; }
// 미리보기에서만 쓰는 「넣어 본 값」 — 입력칸을 덮어 가린다. 정적 경로에서는 투명해 자리만 지킨다
.cs__over {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: $stage-paper;
color: $color;
font-size: 14px;
font-weight: 700;
line-height: 1;
opacity: 0;
pointer-events: none;
}
// ── 격자 미리보기 루프 ──────────────────────────────────────
// 띠는 한 프레임에 내려오고(그 장이 최대 변화 = 대표 이미지), 물러남은 네 프레임에 걸쳐 천천히 —
// 그래야 퇴장 장면이 등장 장면을 이기지 않는다. 복귀는 89% 에 끝난다
@keyframes cs-strip {
0%, 20% { transform: translateY(-110%); }
25%, 71% { transform: translateY(0); }
89%, 100% { transform: translateY(-110%); }
}
// 넣어 본 6 → 최대치 4 는 한 프레임 컷. 크로스페이드를 쓰면 두 숫자가 반투명하게 겹친다
@keyframes cs-over {
0%, 20% { opacity: 1; }
25%, 100% { opacity: 0; }
}
// 되튕김 — 최대치로 돌아오며 제자리를 한 번 지나친다 (오버슛 이징)
@keyframes cs-bounce {
0%, 25% { transform: translateY(0) scale(1); }
29% { transform: translateY(-10px) scale(1.16); }
40%, 100% { transform: translateY(0) scale(1); }
}
@keyframes cs-warn {
0%, 20% { opacity: 0; }
25%, 71% { opacity: 1; }
89%, 100% { opacity: 0; }
}
.cs.is-demo {
.cs__strip {
animation-name: cs-strip;
animation-duration: $duration;
animation-timing-function: $ease-out;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.cs__over {
animation-name: cs-over;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.cs__num {
animation-name: cs-bounce;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.cs__step::after {
animation-name: cs-warn;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
}
@media (max-width: 340px) {
.cs { padding: $sp-2; }
.cs__kicker { font-size: 10px; }
.cs__thumb { width: 32px; height: 32px; }
.cs__name { font-size: 11px; }
.cs__price { font-size: 10px; }
.cs__band { padding-top: $sp-5; }
.cs__strip { height: 20px; font-size: 10px; }
.cs__strip svg { width: 14px; height: 14px; }
.cs__qty { margin-top: $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
// 움직임만 뺀다 — 한도에 걸린 결과(띠·붉은 테두리·최대치 숫자)는 그대로 보인다
.cs.is-demo .cs__strip { animation: none !important; transform: translateY(0) !important; }
.cs.is-demo .cs__over { animation: none !important; opacity: 0 !important; }
.cs.is-demo .cs__num { animation: none !important; transform: none !important; }
.cs.is-demo .cs__step::after { animation: none !important; opacity: 1 !important; }
.cs__strip { transition: none !important; }
.cs__step::after { transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cs {
box-sizing: border-box;
width: min(300px, 92vw);
padding: 12px;
border-radius: 16px;
background: #fff7e6;
color: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.cs * {
box-sizing: border-box;
}
.cs__kicker {
margin: 0 0 8px;
font-size: 11px;
font-weight: 700;
line-height: 1.3;
letter-spacing: 0.04em;
color: rgba(23, 20, 26, 0.72);
}
.cs__band {
position: relative;
overflow: hidden;
padding-top: 24px;
border-radius: 8px;
}
.cs__row {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
border-radius: 8px;
background: rgba(23, 20, 26, 0.05);
}
.cs__thumb {
flex: 0 0 auto;
width: 40px;
height: 40px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.16);
}
.cs__info {
display: grid;
gap: 4px;
min-width: 0;
}
.cs__name {
font-size: 12px;
font-weight: 700;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cs__price {
font-size: 11px;
line-height: 1.2;
color: rgba(23, 20, 26, 0.62);
}
.cs__strip {
position: absolute;
left: 0;
right: 0;
top: 0;
display: flex;
align-items: center;
gap: 8px;
height: 24px;
margin: 0;
padding: 0 8px;
border-radius: 4px;
background: #c2361a;
color: #fff7e6;
font-size: 11px;
font-weight: 700;
line-height: 1.3;
transform: translateY(-110%);
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.cs.is-over .cs__strip {
transform: translateY(0);
}
.cs__strip svg {
flex: 0 0 auto;
width: 16px;
height: 16px;
}
.cs__qty {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: 12px;
}
.cs__left {
font-size: 11px;
line-height: 1.3;
color: rgba(23, 20, 26, 0.62);
}
.cs__step {
position: relative;
display: flex;
align-items: center;
border: 1px solid rgba(23, 20, 26, 0.2);
border-radius: 12px;
background: #ffffff;
}
.cs__step::after {
content: "";
position: absolute;
inset: -1px;
border: 2px solid #c2361a;
border-radius: 12px;
opacity: 0;
pointer-events: none;
transition: opacity 140ms linear;
}
.cs.is-over .cs__step::after {
opacity: 1;
}
.cs__btn {
width: 28px;
height: 28px;
padding: 0;
border: 0;
border-radius: 12px;
background: transparent;
color: #17141a;
font-size: 16px;
font-weight: 700;
line-height: 1;
cursor: pointer;
}
.cs__btn:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.cs__num {
position: relative;
display: grid;
place-items: center;
width: 44px;
height: 28px;
}
.cs__input {
width: 100%;
height: 100%;
padding: 0;
border: 0;
background: transparent;
color: #17141a;
font-family: inherit;
font-size: 14px;
font-weight: 700;
line-height: 1;
text-align: center;
appearance: textfield;
}
.cs__input::-webkit-outer-spin-button,
.cs__input::-webkit-inner-spin-button {
appearance: none;
margin: 0;
}
.cs__input:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.cs__over {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: #ffffff;
color: #c2361a;
font-size: 14px;
font-weight: 700;
line-height: 1;
opacity: 0;
pointer-events: none;
}
@keyframes cs-strip {
0%, 20% {
transform: translateY(-110%);
}
25%, 71% {
transform: translateY(0);
}
89%, 100% {
transform: translateY(-110%);
}
}
@keyframes cs-over {
0%, 20% {
opacity: 1;
}
25%, 100% {
opacity: 0;
}
}
@keyframes cs-bounce {
0%, 25% {
transform: translateY(0) scale(1);
}
29% {
transform: translateY(-10px) scale(1.16);
}
40%, 100% {
transform: translateY(0) scale(1);
}
}
@keyframes cs-warn {
0%, 20% {
opacity: 0;
}
25%, 71% {
opacity: 1;
}
89%, 100% {
opacity: 0;
}
}
.cs.is-demo .cs__strip {
animation-name: cs-strip;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.cs.is-demo .cs__over {
animation-name: cs-over;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.cs.is-demo .cs__num {
animation-name: cs-bounce;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.cs.is-demo .cs__step::after {
animation-name: cs-warn;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@media (max-width: 340px) {
.cs {
padding: 8px;
}
.cs__kicker {
font-size: 10px;
}
.cs__thumb {
width: 32px;
height: 32px;
}
.cs__name {
font-size: 11px;
}
.cs__price {
font-size: 10px;
}
.cs__band {
padding-top: 20px;
}
.cs__strip {
height: 20px;
font-size: 10px;
}
.cs__strip svg {
width: 14px;
height: 14px;
}
.cs__qty {
margin-top: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.cs.is-demo .cs__strip {
animation: none !important;
transform: translateY(0) !important;
}
.cs.is-demo .cs__over {
animation: none !important;
opacity: 0 !important;
}
.cs.is-demo .cs__num {
animation: none !important;
transform: none !important;
}
.cs.is-demo .cs__step::after {
animation: none !important;
opacity: 1 !important;
}
.cs__strip {
transition: none !important;
}
.cs__step::after {
transition: none !important;
}
}// 재고 한도 Quantity — 재고보다 큰 수는 최대치로 되돌리고 경고 띠를 잠시 내린다
var cs = document.getElementById('cs');
var input = document.getElementById('csInput');
var stock = 4;
var backTimer = 0;
function clampQty(wanted) {
cs.classList.remove('is-demo'); // 사람이 손대면 미리보기 루프는 끝난다
var over = wanted > stock;
input.value = String(Math.max(1, Math.min(stock, wanted)));
cs.classList.toggle('is-over', over);
if (over) {
clearTimeout(backTimer);
backTimer = setTimeout(function () { cs.classList.remove('is-over'); }, 1600);
}
}
document.getElementById('csPlus').addEventListener('click', function () {
clampQty(Number(input.value) + 1);
});
document.getElementById('csMinus').addEventListener('click', function () {
clampQty(Number(input.value) - 1);
});
input.addEventListener('input', function () {
if (input.value === '') { return; } // 지우는 중에는 되튕기지 않는다
clampQty(Number(input.value));
});import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./CartStockLimitClamp.module.scss";
export type CartStockLimitClampProps = {
kicker?: string;
productName?: string;
price?: string;
stockLeft?: string;
warning?: string;
qtyLabel?: string;
minusLabel?: string;
plusLabel?: string;
groupLabel?: string;
/** 남은 재고 — 이 수를 넘는 입력은 최대치로 되튕긴다 */
stock?: number;
/** 격자 미리보기용 2초 자동 루프. 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// ambient 가 얇아 DOM 요소·이벤트 타입을 못 쓴다 — 필요한 꼴만 로컬 타입으로 선언
type InputChange = { target: { value: string } };
type TimerBox = { current: number | null };
function WarningIcon() {
return (
<svg viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d="M215.46,216H40.54C27.92,216,20,202.79,26.13,192.09L113.59,40.22c6.3-11,22.52-11,28.82,0l87.46,151.87C236,202.79,228.08,216,215.46,216Z" opacity="0.2"/><path d="M236.8,188.09,149.35,36.22h0a24.76,24.76,0,0,0-42.7,0L19.2,188.09a23.51,23.51,0,0,0,0,23.72A24.35,24.35,0,0,0,40.55,224h174.9a24.35,24.35,0,0,0,21.33-12.19A23.51,23.51,0,0,0,236.8,188.09ZM222.93,203.8a8.5,8.5,0,0,1-7.48,4.2H40.55a8.5,8.5,0,0,1-7.48-4.2,7.59,7.59,0,0,1,0-7.72L120.52,44.21a8.75,8.75,0,0,1,15,0l87.45,151.87A7.59,7.59,0,0,1,222.93,203.8ZM120,144V104a8,8,0,0,1,16,0v40a8,8,0,0,1-16,0Zm20,36a12,12,0,1,1-12-12A12,12,0,0,1,140,180Z"/></svg>
);
}
export default function CartStockLimitClamp({
kicker = "장바구니",
productName = "겨울 니트 아이보리",
price = "64,000원",
stockLeft = "재고 4개",
warning = "남은 수량 4개",
qtyLabel = "수량",
minusLabel = "수량 줄이기",
plusLabel = "수량 늘리기",
groupLabel = "재고 한도 수량 입력",
stock = 4,
demo = true,
duration = 2000,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#c2361a",
}: CartStockLimitClampProps) {
const [qty, setQty] = useState(stock);
const [over, setOver] = useState(false);
const [live, setLive] = useState(demo);
const timer: TimerBox = useRef<number>(0);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 재고보다 큰 수는 최대치로 되돌리고 경고 띠를 잠시 내린다
function clampQty(wanted: number) {
setLive(false);
const tooMany = wanted > stock;
setQty(Math.max(1, Math.min(stock, wanted)));
setOver(tooMany);
if (timer.current) clearTimeout(timer.current);
if (tooMany) timer.current = setTimeout(() => setOver(false), 1600);
}
function onType(e: InputChange) {
if (e.target.value === "") return; // 지우는 중에는 되튕기지 않는다
clampQty(Number(e.target.value));
}
const cls = [styles.cs, live ? styles.isDemo : "", over ? styles.isOver : ""].filter(Boolean).join(" ");
return (
<section className={cls} style={vars} aria-label={groupLabel}>
<p className={styles.kicker}>{kicker}</p>
<div className={styles.band}>
{/* 경고 띠: 줄 위로 내려와 남은 개수를 말한다 */}
<p className={styles.strip} role="alert">
<WarningIcon />
<span>{warning}</span>
</p>
<div className={styles.row}>
<span className={styles.thumb} aria-hidden="true"></span>
<span className={styles.info}>
<b className={styles.name}>{productName}</b>
<span className={styles.price}>{price}</span>
</span>
</div>
</div>
<div className={styles.qty}>
<span className={styles.left}>{stockLeft}</span>
<span className={styles.step}>
<button className={styles.btn} type="button" onClick={() => clampQty(qty - 1)} aria-label={minusLabel}>−</button>
<span className={styles.num}>
<input
className={styles.input}
type="number"
inputMode="numeric"
value={qty}
min={1}
max={stock}
onChange={onType}
aria-label={qtyLabel}
/>
{/* 미리보기 전용: 재고를 넘겨 넣은 값. 손으로 쓰는 정적 상태에서는 투명하게 비켜 있고 자리만 지킨다 */}
<b className={styles.over} aria-hidden="true">{stock + 2}</b>
</span>
<button className={styles.btn} type="button" onClick={() => clampQty(qty + 1)} aria-label={plusLabel}>+</button>
</span>
</div>
</section>
);
}04 · Quantity that springs back when it passes the stock limit — Measured area 5.05% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
The render logged 5.05% changed area at intensity 123.9, and the pattern fits limited-stock products or per-customer purchase caps.
// Bounce — it passes through its own seat once on the way back (overshoot easing)
@keyframes cs-bounce {
0%, 25% { transform: translateY(0) scale(1); }
29% { transform: translateY(-10px) scale(1.16); }
40%, 100% { transform: translateY(0) scale(1); }
}
05Payment method card that opens only for the option you picked
What happens on screen
Pick one of the three payment method cards, and only that card's detail panel unfolds from zero height while the previously open one closes over the same duration. The radio's :checked does the opening and closing, aria-expanded tracks keyboard arrow moves in real time, and the panel expands inside the document flow so it never pokes out at phone width, logging 17.7% changed area over 8 of 23 sampled frames.

<main class="stage">
<form class="pm is-demo" id="pm">
<fieldset class="pm__set">
<legend class="pm__legend">Payment method</legend>
<div class="pm__item">
<label class="pm__row" aria-expanded="true">
<input class="pm__radio" type="radio" name="payMethod" value="card" checked>
<span class="pm__ic" aria-hidden="true"><svg viewBox="0 0 256 256" fill="currentColor"><path d="M232,96v96a8,8,0,0,1-8,8H32a8,8,0,0,1-8-8V96Z" opacity="0.2"/><path d="M224,48H32A16,16,0,0,0,16,64V192a16,16,0,0,0,16,16H224a16,16,0,0,0,16-16V64A16,16,0,0,0,224,48Zm0,16V88H32V64Zm0,128H32V104H224v88Zm-16-24a8,8,0,0,1-8,8H168a8,8,0,0,1,0-16h32A8,8,0,0,1,208,168Zm-64,0a8,8,0,0,1-8,8H120a8,8,0,0,1,0-16h16A8,8,0,0,1,144,168Z"/></svg></span>
<span class="pm__name">Card</span>
<span class="pm__dot" aria-hidden="true"></span>
</label>
<div class="pm__fold">
<p class="pm__detail"><span class="pm__dtext">Enter the card number and pick the installment plan here</span></p>
</div>
</div>
<div class="pm__item">
<label class="pm__row" aria-expanded="false">
<input class="pm__radio" type="radio" name="payMethod" value="wallet">
<span class="pm__ic" aria-hidden="true"><svg viewBox="0 0 256 256" fill="currentColor"><path d="M224,80V192a8,8,0,0,1-8,8H56a16,16,0,0,1-16-16V56A16,16,0,0,0,56,72H216A8,8,0,0,1,224,80Z" opacity="0.2"/><path d="M216,64H56a8,8,0,0,1,0-16H192a8,8,0,0,0,0-16H56A24,24,0,0,0,32,56V184a24,24,0,0,0,24,24H216a16,16,0,0,0,16-16V80A16,16,0,0,0,216,64Zm0,128H56a8,8,0,0,1-8-8V78.63A23.84,23.84,0,0,0,56,80H216Zm-48-60a12,12,0,1,1,12,12A12,12,0,0,1,168,132Z"/></svg></span>
<span class="pm__name">Wallet</span>
<span class="pm__dot" aria-hidden="true"></span>
</label>
<div class="pm__fold">
<p class="pm__detail"><span class="pm__dtext">Confirm with a fingerprint in the app and you are done</span></p>
</div>
</div>
<div class="pm__item">
<label class="pm__row" aria-expanded="false">
<input class="pm__radio" type="radio" name="payMethod" value="bank">
<span class="pm__ic" aria-hidden="true"><svg viewBox="0 0 256 256" fill="currentColor"><path d="M232,96H24L128,32Z" opacity="0.2"/><path d="M24,104H48v64H32a8,8,0,0,0,0,16H224a8,8,0,0,0,0-16H208V104h24a8,8,0,0,0,4.19-14.81l-104-64a8,8,0,0,0-8.38,0l-104,64A8,8,0,0,0,24,104Zm40,0H96v64H64Zm80,0v64H112V104Zm48,64H160V104h32ZM128,41.39,203.74,88H52.26ZM248,208a8,8,0,0,1-8,8H16a8,8,0,0,1,0-16H240A8,8,0,0,1,248,208Z"/></svg></span>
<span class="pm__name">Bank transfer</span>
<span class="pm__dot" aria-hidden="true"></span>
</label>
<div class="pm__fold">
<p class="pm__detail"><span class="pm__dtext">The account to pay into appears on the next screen</span></p>
</div>
</div>
</fieldset>
</form>
</main>@use "../../../../site/static/demo-tokens" as *;
// 고른 결제 수단만 아래로 펼쳐지는 카드 — 상세 칸은 grid-template-rows 0fr→1fr 로 문서 흐름 안에서 편다.
// 절대배치 패널로 띄우면 320px 에서 실제 높이가 화면을 넘긴다(BD9)
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
.pm {
box-sizing: border-box;
width: min(300px, 92vw);
color: $subject-cream;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.pm * { box-sizing: border-box; }
.pm__set {
display: grid;
gap: $sp-1;
margin: 0;
padding: 0;
border: 0;
}
.pm__legend {
padding: 0;
margin-bottom: $sp-2;
font-size: 11px;
font-weight: 700;
line-height: 1.3;
letter-spacing: .04em;
color: rgba($subject-cream, .62);
}
.pm__item {
position: relative;
border-radius: $r-control;
background: rgba($subject-cream, .08);
}
// 고른 표시 — 테두리·바탕을 따로 그린 겹으로 두고 opacity 만 켠다.
// 그래야 미리보기 루프에서 컷으로 갈아 끼워도 두 카드가 반투명하게 겹치지 않는다
.pm__item::before {
content: "";
position: absolute;
inset: 0;
border: 2px solid $color;
border-radius: $r-control;
background: rgba($subject-blue, .18);
opacity: 0;
pointer-events: none;
transition: opacity $dur-quick linear;
}
.pm__item:has(.pm__radio:checked)::before { opacity: 1; }
.pm__row {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-2;
cursor: pointer;
}
.pm__radio {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
opacity: 0;
pointer-events: none;
}
.pm__ic {
flex: 0 0 auto;
display: inline-grid;
place-items: center;
width: 18px;
height: 18px;
color: rgba($subject-cream, .72);
}
.pm__ic svg { width: 18px; height: 18px; }
.pm__item:has(.pm__radio:checked) .pm__ic { color: $subject-cream; }
.pm__name {
flex: 1 1 auto;
font-size: 12px;
font-weight: 700;
line-height: 1.2;
}
.pm__dot {
position: relative;
flex: 0 0 auto;
width: 16px;
height: 16px;
border: 2px solid rgba($subject-cream, .45);
border-radius: 50%;
}
.pm__dot::after {
content: "";
position: absolute;
inset: 2px;
border-radius: 50%;
background: $color;
opacity: 0;
transform: scale(.4);
transition: opacity $dur-instant linear, transform $dur-quick $easing;
}
.pm__item:has(.pm__radio:checked) .pm__dot { border-color: $color; }
.pm__item:has(.pm__radio:checked) .pm__dot::after { opacity: 1; transform: scale(1); }
// 키보드로 옮겨 다닐 때도 어느 카드에 서 있는지 보인다
.pm__item:has(.pm__radio:focus-visible) { outline: 2px solid $subject-cream; outline-offset: 2px; }
// 상세 칸 — 닫힘 0fr, 열림 1fr. 높이를 px 로 짐작하지 않으니 글자가 길어져도 맞는다
.pm__fold {
position: relative;
z-index: 1;
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;
margin: 0;
overflow: hidden;
}
.pm__dtext {
display: block;
padding: 0 $sp-2 $sp-2;
font-size: 11px;
line-height: 1.4;
color: rgba($subject-cream, .78);
}
// ── 격자 미리보기 루프: 1번 열림 → 2번 열림 → 1번 복귀. 상태 교체는 컷, 곁들이는 움직임은 transform ──
@keyframes pm-fold-1 {
0%, 42% { grid-template-rows: 1fr; }
46%, 84% { grid-template-rows: 0fr; }
88%, 100% { grid-template-rows: 1fr; }
}
@keyframes pm-fold-2 {
0%, 42% { grid-template-rows: 0fr; }
46%, 84% { grid-template-rows: 1fr; }
88%, 100% { grid-template-rows: 0fr; }
}
@keyframes pm-on-1 {
0%, 42% { opacity: 1; }
46%, 84% { opacity: 0; }
88%, 100% { opacity: 1; }
}
@keyframes pm-on-2 {
0%, 42% { opacity: 0; }
46%, 84% { opacity: 1; }
88%, 100% { opacity: 0; }
}
@keyframes pm-dot-1 {
0%, 42% { opacity: 1; transform: scale(1); }
46%, 84% { opacity: 0; transform: scale(.4); }
88%, 100% { opacity: 1; transform: scale(1); }
}
@keyframes pm-dot-2 {
0%, 42% { opacity: 0; transform: scale(.4); }
46%, 84% { opacity: 1; transform: scale(1); }
88%, 100% { opacity: 0; transform: scale(.4); }
}
// 컷만으로는 움직인 프레임이 두어 장뿐이라 측정이 안 찬다 — 펼쳐진 글이 한 번 올라서며 프레임을 채운다.
// 투명도가 아니라 transform 이라 중간 프레임이 유령이 되지 않는다
@keyframes pm-rise-1 {
0%, 87% { transform: translateY(0); }
88% { transform: translateY(10px); }
100% { transform: translateY(0); }
}
@keyframes pm-rise-2 {
0%, 45% { transform: translateY(0); }
46% { transform: translateY(10px); }
62%, 100% { transform: translateY(0); }
}
@keyframes pm-pop-1 {
0%, 87% { transform: scale(1); }
88% { transform: scale(1.35); }
100% { transform: scale(1); }
}
@keyframes pm-pop-2 {
0%, 45% { transform: scale(1); }
46% { transform: scale(1.35); }
62%, 100% { transform: scale(1); }
}
.pm.is-demo {
.pm__fold,
.pm__item::before,
.pm__dot::after {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.pm__dtext,
.pm__ic {
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.pm__item:nth-child(2) {
.pm__fold { animation-name: pm-fold-1; }
&::before { animation-name: pm-on-1; }
.pm__dot::after { animation-name: pm-dot-1; }
.pm__dtext { animation-name: pm-rise-1; }
.pm__ic { animation-name: pm-pop-1; }
}
.pm__item:nth-child(3) {
.pm__fold { animation-name: pm-fold-2; }
&::before { animation-name: pm-on-2; }
.pm__dot::after { animation-name: pm-dot-2; }
.pm__dtext { animation-name: pm-rise-2; }
.pm__ic { animation-name: pm-pop-2; }
}
}
@media (max-width: 340px) {
.pm__legend { margin-bottom: $sp-1; font-size: 10px; }
.pm__row { padding: $sp-1 $sp-2; }
.pm__name { font-size: 11px; }
.pm__dtext { padding: 0 $sp-2 $sp-1; font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
// 정지 상태에서도 「1번이 골라져 펼쳐져 있다」 는 것은 보인다 — 움직임만 뺀다
.pm.is-demo .pm__fold,
.pm.is-demo .pm__item::before,
.pm.is-demo .pm__dot::after,
.pm.is-demo .pm__dtext,
.pm.is-demo .pm__ic { animation: none !important; }
.pm.is-demo .pm__dtext,
.pm.is-demo .pm__ic { transform: none !important; }
.pm__fold { transition: none !important; }
.pm__item::before { transition: none !important; }
.pm__dot::after { transition: none !important; }
}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);
}
.pm {
box-sizing: border-box;
width: min(300px, 92vw);
color: #fff7e6;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.pm * {
box-sizing: border-box;
}
.pm__set {
display: grid;
gap: 4px;
margin: 0;
padding: 0;
border: 0;
}
.pm__legend {
padding: 0;
margin-bottom: 8px;
font-size: 11px;
font-weight: 700;
line-height: 1.3;
letter-spacing: 0.04em;
color: rgba(255, 247, 230, 0.62);
}
.pm__item {
position: relative;
border-radius: 12px;
background: rgba(255, 247, 230, 0.08);
}
.pm__item::before {
content: "";
position: absolute;
inset: 0;
border: 2px solid #2f6df6;
border-radius: 12px;
background: rgba(47, 109, 246, 0.18);
opacity: 0;
pointer-events: none;
transition: opacity 220ms linear;
}
.pm__item:has(.pm__radio:checked)::before {
opacity: 1;
}
.pm__row {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
cursor: pointer;
}
.pm__radio {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
opacity: 0;
pointer-events: none;
}
.pm__ic {
flex: 0 0 auto;
display: inline-grid;
place-items: center;
width: 18px;
height: 18px;
color: rgba(255, 247, 230, 0.72);
}
.pm__ic svg {
width: 18px;
height: 18px;
}
.pm__item:has(.pm__radio:checked) .pm__ic {
color: #fff7e6;
}
.pm__name {
flex: 1 1 auto;
font-size: 12px;
font-weight: 700;
line-height: 1.2;
}
.pm__dot {
position: relative;
flex: 0 0 auto;
width: 16px;
height: 16px;
border: 2px solid rgba(255, 247, 230, 0.45);
border-radius: 50%;
}
.pm__dot::after {
content: "";
position: absolute;
inset: 2px;
border-radius: 50%;
background: #2f6df6;
opacity: 0;
transform: scale(0.4);
transition: opacity 140ms linear, transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.pm__item:has(.pm__radio:checked) .pm__dot {
border-color: #2f6df6;
}
.pm__item:has(.pm__radio:checked) .pm__dot::after {
opacity: 1;
transform: scale(1);
}
.pm__item:has(.pm__radio:focus-visible) {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.pm__fold {
position: relative;
z-index: 1;
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.pm__item:has(.pm__radio:checked) .pm__fold {
grid-template-rows: 1fr;
}
.pm__detail {
min-height: 0;
margin: 0;
overflow: hidden;
}
.pm__dtext {
display: block;
padding: 0 8px 8px;
font-size: 11px;
line-height: 1.4;
color: rgba(255, 247, 230, 0.78);
}
@keyframes pm-fold-1 {
0%, 42% {
grid-template-rows: 1fr;
}
46%, 84% {
grid-template-rows: 0fr;
}
88%, 100% {
grid-template-rows: 1fr;
}
}
@keyframes pm-fold-2 {
0%, 42% {
grid-template-rows: 0fr;
}
46%, 84% {
grid-template-rows: 1fr;
}
88%, 100% {
grid-template-rows: 0fr;
}
}
@keyframes pm-on-1 {
0%, 42% {
opacity: 1;
}
46%, 84% {
opacity: 0;
}
88%, 100% {
opacity: 1;
}
}
@keyframes pm-on-2 {
0%, 42% {
opacity: 0;
}
46%, 84% {
opacity: 1;
}
88%, 100% {
opacity: 0;
}
}
@keyframes pm-dot-1 {
0%, 42% {
opacity: 1;
transform: scale(1);
}
46%, 84% {
opacity: 0;
transform: scale(0.4);
}
88%, 100% {
opacity: 1;
transform: scale(1);
}
}
@keyframes pm-dot-2 {
0%, 42% {
opacity: 0;
transform: scale(0.4);
}
46%, 84% {
opacity: 1;
transform: scale(1);
}
88%, 100% {
opacity: 0;
transform: scale(0.4);
}
}
@keyframes pm-rise-1 {
0%, 87% {
transform: translateY(0);
}
88% {
transform: translateY(10px);
}
100% {
transform: translateY(0);
}
}
@keyframes pm-rise-2 {
0%, 45% {
transform: translateY(0);
}
46% {
transform: translateY(10px);
}
62%, 100% {
transform: translateY(0);
}
}
@keyframes pm-pop-1 {
0%, 87% {
transform: scale(1);
}
88% {
transform: scale(1.35);
}
100% {
transform: scale(1);
}
}
@keyframes pm-pop-2 {
0%, 45% {
transform: scale(1);
}
46% {
transform: scale(1.35);
}
62%, 100% {
transform: scale(1);
}
}
.pm.is-demo .pm__fold,
.pm.is-demo .pm__item::before,
.pm.is-demo .pm__dot::after {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.pm.is-demo .pm__dtext,
.pm.is-demo .pm__ic {
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.pm.is-demo .pm__item:nth-child(2) .pm__fold {
animation-name: pm-fold-1;
}
.pm.is-demo .pm__item:nth-child(2)::before {
animation-name: pm-on-1;
}
.pm.is-demo .pm__item:nth-child(2) .pm__dot::after {
animation-name: pm-dot-1;
}
.pm.is-demo .pm__item:nth-child(2) .pm__dtext {
animation-name: pm-rise-1;
}
.pm.is-demo .pm__item:nth-child(2) .pm__ic {
animation-name: pm-pop-1;
}
.pm.is-demo .pm__item:nth-child(3) .pm__fold {
animation-name: pm-fold-2;
}
.pm.is-demo .pm__item:nth-child(3)::before {
animation-name: pm-on-2;
}
.pm.is-demo .pm__item:nth-child(3) .pm__dot::after {
animation-name: pm-dot-2;
}
.pm.is-demo .pm__item:nth-child(3) .pm__dtext {
animation-name: pm-rise-2;
}
.pm.is-demo .pm__item:nth-child(3) .pm__ic {
animation-name: pm-pop-2;
}
@media (max-width: 340px) {
.pm__legend {
margin-bottom: 4px;
font-size: 10px;
}
.pm__row {
padding: 4px 8px;
}
.pm__name {
font-size: 11px;
}
.pm__dtext {
padding: 0 8px 4px;
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.pm.is-demo .pm__fold,
.pm.is-demo .pm__item::before,
.pm.is-demo .pm__dot::after,
.pm.is-demo .pm__dtext,
.pm.is-demo .pm__ic {
animation: none !important;
}
.pm.is-demo .pm__dtext,
.pm.is-demo .pm__ic {
transform: none !important;
}
.pm__fold {
transition: none !important;
}
.pm__item::before {
transition: none !important;
}
.pm__dot::after {
transition: none !important;
}
}// 고른 수단의 상세 칸만 편다 — 여닫는 힘은 :checked 가 내고, JS 는 aria-expanded 만 맞춘다
var pm = document.getElementById('pm');
var rows = pm.querySelectorAll('.pm__row');
function syncExpanded() {
for (var i = 0; i < rows.length; i++) {
var picked = rows[i].querySelector('.pm__radio').checked;
rows[i].setAttribute('aria-expanded', picked ? 'true' : 'false');
}
}
// 라디오는 키보드 화살표로도 옮겨 다닌다 — change 하나면 마우스·키보드가 같이 걸린다
pm.addEventListener('change', function () {
pm.classList.remove('is-demo');
syncExpanded();
});
syncExpanded();import { useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./PayMethodCardExpand.module.scss";
export type PayMethodCardExpandProps = {
legend?: string;
cardName?: string;
cardDetail?: string;
walletName?: string;
walletDetail?: string;
bankName?: string;
bankDetail?: string;
/** 격자 미리보기용 2초 자동 루프. 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// ambient 가 얇아 이벤트 타입을 못 쓴다 — 필요한 꼴만 로컬 타입으로 선언
type Method = { value: string; name: string; detail: string; icon: ReactNode };
function CardIcon() {
return (
<svg viewBox="0 0 256 256" fill="currentColor"><path d="M232,96v96a8,8,0,0,1-8,8H32a8,8,0,0,1-8-8V96Z" opacity="0.2"/><path d="M224,48H32A16,16,0,0,0,16,64V192a16,16,0,0,0,16,16H224a16,16,0,0,0,16-16V64A16,16,0,0,0,224,48Zm0,16V88H32V64Zm0,128H32V104H224v88Zm-16-24a8,8,0,0,1-8,8H168a8,8,0,0,1,0-16h32A8,8,0,0,1,208,168Zm-64,0a8,8,0,0,1-8,8H120a8,8,0,0,1,0-16h16A8,8,0,0,1,144,168Z"/></svg>
);
}
function WalletIcon() {
return (
<svg viewBox="0 0 256 256" fill="currentColor"><path d="M224,80V192a8,8,0,0,1-8,8H56a16,16,0,0,1-16-16V56A16,16,0,0,0,56,72H216A8,8,0,0,1,224,80Z" opacity="0.2"/><path d="M216,64H56a8,8,0,0,1,0-16H192a8,8,0,0,0,0-16H56A24,24,0,0,0,32,56V184a24,24,0,0,0,24,24H216a16,16,0,0,0,16-16V80A16,16,0,0,0,216,64Zm0,128H56a8,8,0,0,1-8-8V78.63A23.84,23.84,0,0,0,56,80H216Zm-48-60a12,12,0,1,1,12,12A12,12,0,0,1,168,132Z"/></svg>
);
}
function BankIcon() {
return (
<svg viewBox="0 0 256 256" fill="currentColor"><path d="M232,96H24L128,32Z" opacity="0.2"/><path d="M24,104H48v64H32a8,8,0,0,0,0,16H224a8,8,0,0,0,0-16H208V104h24a8,8,0,0,0,4.19-14.81l-104-64a8,8,0,0,0-8.38,0l-104,64A8,8,0,0,0,24,104Zm40,0H96v64H64Zm80,0v64H112V104Zm48,64H160V104h32ZM128,41.39,203.74,88H52.26ZM248,208a8,8,0,0,1-8,8H16a8,8,0,0,1,0-16H240A8,8,0,0,1,248,208Z"/></svg>
);
}
export default function PayMethodCardExpand({
legend = "결제 수단",
cardName = "카드",
cardDetail = "카드 번호와 할부 개월을 여기서 고릅니다",
walletName = "간편결제",
walletDetail = "앱에서 지문으로 확인하면 끝납니다",
bankName = "계좌이체",
bankDetail = "입금하실 계좌를 다음 화면에 띄웁니다",
demo = true,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: PayMethodCardExpandProps) {
const [picked, setPicked] = useState("card");
const [live, setLive] = useState(demo);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const methods: Method[] = [
{ value: "card", name: cardName, detail: cardDetail, icon: <CardIcon /> },
{ value: "wallet", name: walletName, detail: walletDetail, icon: <WalletIcon /> },
{ value: "bank", name: bankName, detail: bankDetail, icon: <BankIcon /> },
];
// 라디오는 키보드 화살표로도 옮겨 다닌다 — onChange 하나면 마우스·키보드가 같이 걸린다
function pick(value: string) {
setLive(false);
setPicked(value);
}
const loopClass = [styles.loopA, styles.loopB, ""];
return (
<form className={[styles.pm, live ? styles.isDemo : ""].filter(Boolean).join(" ")} style={vars}>
<fieldset className={styles.set}>
<legend className={styles.legend}>{legend}</legend>
{methods.map((m, i) => (
<div key={m.value} className={[styles.item, loopClass[i]].filter(Boolean).join(" ")}>
<label className={styles.row} aria-expanded={picked === m.value}>
<input
className={styles.radio}
type="radio"
name="payMethod"
value={m.value}
checked={picked === m.value}
onChange={() => pick(m.value)}
/>
<span className={styles.ic} aria-hidden="true">{m.icon}</span>
<span className={styles.name}>{m.name}</span>
<span className={styles.dot} aria-hidden="true"></span>
</label>
{/* 상세 칸 — 닫힘 0fr, 열림 1fr. 높이를 px 로 짐작하지 않으니 글자가 길어져도 맞는다 */}
<div className={styles.fold}>
<p className={styles.detail}><span className={styles.dtext}>{m.detail}</span></p>
</div>
</div>
))}
</fieldset>
</form>
);
}05 · Payment method card that opens only the option you picked — Measured area 17.7% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
Use it wherever a checkout chooses between card, wallet, and bank transfer.
// Detail panel — closed 0fr, open 1fr. No pixel guessing, so longer text still fits
.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;
}
06Shipping fields that fold away and fill themselves in
What moves
Turn on "same as orderer" and the shipping fieldset folds into a single summary line that already carries the orderer's name and address. JavaScript copies the values, the folded fields leave the focus order through aria-hidden="true" and inert so Tab never wanders through them, and the render logged 22.416% changed area at intensity 55.7.

<main class="stage">
<section class="sa is-demo" id="sa" aria-label="Shipping address form">
<div class="sa__who" data-tel="010-2345-6789">
<p class="sa__kicker">Orderer</p>
<p class="sa__line">
<span class="sa__ic" aria-hidden="true"><svg viewBox="0 0 256 256" fill="currentColor"><path d="M192,96a64,64,0,1,1-64-64A64,64,0,0,1,192,96Z" opacity="0.2"/><path d="M230.92,212c-15.23-26.33-38.7-45.21-66.09-54.16a72,72,0,1,0-73.66,0C63.78,166.78,40.31,185.66,25.08,212a8,8,0,1,0,13.85,8c18.84-32.56,52.14-52,89.07-52s70.23,19.44,89.07,52a8,8,0,1,0,13.85-8ZM72,96a56,56,0,1,1,56,56A56.06,56.06,0,0,1,72,96Z"/></svg></span>
<b class="sa__v" id="saWhoName">Kim Doyun</b>
</p>
<p class="sa__line">
<span class="sa__ic" aria-hidden="true"><svg viewBox="0 0 256 256" fill="currentColor"><path d="M216,120v96H152V152H104v64H40V120a8,8,0,0,1,2.34-5.66l80-80a8,8,0,0,1,11.32,0l80,80A8,8,0,0,1,216,120Z" opacity="0.2"/><path d="M219.31,108.68l-80-80a16,16,0,0,0-22.62,0l-80,80A15.87,15.87,0,0,0,32,120v96a8,8,0,0,0,8,8h64a8,8,0,0,0,8-8V160h32v56a8,8,0,0,0,8,8h64a8,8,0,0,0,8-8V120A15.87,15.87,0,0,0,219.31,108.68ZM208,208H160V152a8,8,0,0,0-8-8H104a8,8,0,0,0-8,8v56H48V120l80-80,80,80Z"/></svg></span>
<span class="sa__v" id="saWhoAddr">12 Yanghwa-ro, Mapo-gu, Seoul, 3F</span>
</p>
</div>
<label class="sa__same">
<input class="sa__box" type="checkbox" id="saSame" checked>
<span class="sa__tick" aria-hidden="true"><svg viewBox="0 0 256 256" fill="currentColor"><path d="M232,56V200a16,16,0,0,1-16,16H40a16,16,0,0,1-16-16V56A16,16,0,0,1,40,40H216A16,16,0,0,1,232,56Z" opacity="0.2"/><path d="M205.66,85.66l-96,96a8,8,0,0,1-11.32,0l-40-40a8,8,0,0,1,11.32-11.32L104,164.69l90.34-90.35a8,8,0,0,1,11.32,11.32Z"/></svg></span>
<span class="sa__label">Same as orderer</span>
</label>
<!-- 접혔을 때 남는 요약 한 줄 — 값은 JS 가 Orderer 칸에서 그대로 옮겨 적는다 -->
<div class="sa__sumfold">
<p class="sa__sum"><b class="sa__sumtext" id="saSum">Kim Doyun · 12 Yanghwa-ro, Mapo-gu, Seoul, 3F</b></p>
</div>
<div class="sa__fold" id="saFold" aria-hidden="true" inert>
<div class="sa__inner">
<p class="sa__field">
<label class="sa__fk" for="saName">Recipient</label>
<input class="sa__fv" id="saName" type="text" value="Kim Doyun">
</p>
<p class="sa__field">
<label class="sa__fk" for="saAddr">Address</label>
<input class="sa__fv" id="saAddr" type="text" value="12 Yanghwa-ro, Mapo-gu, Seoul, 3F">
</p>
<p class="sa__field">
<label class="sa__fk" for="saTel">Phone</label>
<input class="sa__fv" id="saTel" type="tel" value="010-2345-6789">
</p>
</div>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 주문자와 같음을 켜면 배송지 칸 묶음이 grid-template-rows 로 접히고 요약 한 줄만 남는다.
// 접히는 칸에는 세로 패딩을 두지 않는다 — 0fr 에서도 패딩은 남아 칸이 완전히 닫히지 않는다
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
.sa {
box-sizing: border-box;
width: min(300px, 92vw);
padding: $sp-3;
border: 1px solid rgba($subject-ink, .14);
border-radius: $r-card;
background: $subject-cream;
color: $subject-ink;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.sa * { box-sizing: border-box; }
.sa__who { margin-bottom: $sp-2; }
.sa__kicker {
margin: 0;
font-size: 11px;
font-weight: 700;
line-height: 1.3;
letter-spacing: .04em;
color: rgba($subject-ink, .72);
}
.sa__line {
display: flex;
align-items: center;
gap: $sp-2;
margin: 0;
min-width: 0;
}
.sa__ic {
flex: 0 0 auto;
display: inline-grid;
place-items: center;
width: 14px;
height: 14px;
color: rgba($subject-ink, .72);
}
.sa__ic svg { width: 14px; height: 14px; }
.sa__v {
font-size: 12px;
font-weight: 500;
line-height: 1.5;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
// ── 「주문자와 같음」 체크 ──
.sa__same {
display: flex;
align-items: center;
gap: $sp-2;
margin-bottom: $sp-2;
cursor: pointer;
}
.sa__box {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
opacity: 0;
pointer-events: none;
}
.sa__tick {
position: relative;
flex: 0 0 auto;
display: inline-grid;
place-items: center;
width: 18px;
height: 18px;
border: 2px solid rgba($subject-ink, .3);
border-radius: $r-xs;
color: $subject-cream;
}
// 켜진 바탕은 따로 그린 겹 — opacity 만 켜므로 미리보기 루프에서 컷으로 갈아 끼워도 색이 섞이지 않는다
.sa__tick::before {
content: "";
position: absolute;
inset: -2px;
border: 2px solid $color;
border-radius: $r-xs;
background: $color;
opacity: 0;
transition: opacity $dur-instant linear;
}
.sa__tick svg {
position: relative;
width: 12px;
height: 12px;
opacity: 0;
transform: scale(.4);
transition: opacity $dur-instant linear, transform $dur-quick $easing;
}
.sa:has(.sa__box:checked) .sa__tick::before { opacity: 1; }
.sa:has(.sa__box:checked) .sa__tick svg { opacity: 1; transform: scale(1); }
.sa:has(.sa__box:focus-visible) .sa__tick { outline: 2px solid $color; outline-offset: 2px; }
.sa__label {
font-size: 12px;
font-weight: 700;
line-height: 1.3;
}
// ── 접혔을 때 남는 요약 한 줄 ──
.sa__sumfold {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows $duration $easing;
}
.sa:has(.sa__box:checked) .sa__sumfold { grid-template-rows: 1fr; }
.sa__sum {
min-height: 0;
margin: 0;
overflow: hidden;
}
.sa__sumtext {
display: block;
border-left: 2px solid $color;
padding-left: $sp-2;
font-size: 11px;
font-weight: 600;
line-height: 1.5;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
// ── 배송지 입력 칸 묶음 ──
.sa__fold {
display: grid;
grid-template-rows: 1fr;
transition: grid-template-rows $duration $easing;
}
.sa:has(.sa__box:checked) .sa__fold { grid-template-rows: 0fr; }
.sa__inner {
display: grid;
gap: $sp-1;
min-height: 0;
overflow: hidden;
}
.sa__field {
display: flex;
align-items: center;
gap: $sp-2;
margin: 0;
}
.sa__fk {
flex: 0 0 auto;
width: 48px;
font-size: 10px;
font-weight: 700;
line-height: 1.4;
color: rgba($subject-ink, .72);
}
.sa__fv {
flex: 1 1 auto;
min-width: 0;
height: 20px;
padding: 0 $sp-2;
border: 1px solid rgba($subject-ink, .2);
border-radius: $r-xs;
background: $stage-paper;
color: $subject-ink;
font-family: inherit;
font-size: 10px;
font-weight: 500;
line-height: 1.4;
}
.sa__fv:focus-visible { outline: 2px solid $color; outline-offset: 1px; }
// ── 격자 미리보기 루프: 접힘 → 펼침 → 접힘. 칸 높이는 컷, 곁들이는 움직임은 transform ──
@keyframes sa-fields {
0%, 40% { grid-template-rows: 0fr; }
44%, 80% { grid-template-rows: 1fr; }
84%, 100% { grid-template-rows: 0fr; }
}
@keyframes sa-sumrow {
0%, 40% { grid-template-rows: 1fr; }
44%, 80% { grid-template-rows: 0fr; }
84%, 100% { grid-template-rows: 1fr; }
}
@keyframes sa-tickbg {
0%, 40% { opacity: 1; }
44%, 80% { opacity: 0; }
84%, 100% { opacity: 1; }
}
@keyframes sa-tickfade {
0%, 40% { opacity: 1; }
44%, 80% { opacity: 0; }
84%, 100% { opacity: 1; }
}
// 컷만으로는 움직인 프레임이 두어 장뿐이라 측정이 안 찬다 — 체크·칸·요약이 저마다 한 번 올라서며 채운다
@keyframes sa-tickpop {
0%, 43% { transform: scale(1); }
44%, 84% { transform: scale(.3); }
96%, 100% { transform: scale(1); }
}
@keyframes sa-rise {
0%, 43% { transform: translateY(0); }
44% { transform: translateY(16px); }
76%, 100% { transform: translateY(0); }
}
// 요약 줄만 옆에서 들어온다 — 세로로 올리면 제 칸(overflow: hidden)에 반쯤 잘린 장이 대표 이미지로 뽑힌다
@keyframes sa-sumrise {
0%, 83% { transform: translateX(0); }
84% { transform: translateX(14px); }
100% { transform: translateX(0); }
}
.sa.is-demo {
.sa__fold,
.sa__sumfold,
.sa__tick::before {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.sa__fold { animation-name: sa-fields; }
.sa__sumfold { animation-name: sa-sumrow; }
.sa__tick::before { animation-name: sa-tickbg; }
// 체크 표시는 켜짐/꺼짐만 컷하고 크기는 부드럽게 — 투명도가 섞이지 않으니 유령 프레임이 안 생긴다
.sa__tick svg {
animation-name: sa-tickfade, sa-tickpop;
animation-duration: $dur-loop, $dur-loop;
animation-timing-function: steps(1, end), $ease-out;
animation-iteration-count: infinite, infinite;
animation-fill-mode: backwards, backwards;
}
.sa__field,
.sa__sumtext {
animation-duration: $dur-loop;
animation-timing-function: $ease-out;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.sa__field { animation-name: sa-rise; }
.sa__field:nth-child(2) { animation-delay: 60ms; }
.sa__field:nth-child(3) { animation-delay: 120ms; }
.sa__sumtext { animation-name: sa-sumrise; }
}
@media (max-width: 340px) {
.sa { padding: $sp-2; }
.sa__kicker { font-size: 10px; }
.sa__v { font-size: 11px; }
.sa__label { font-size: 11px; }
.sa__sumtext { font-size: 10px; }
.sa__fv { height: 18px; }
.sa__fk { width: 44px; }
}
@media (prefers-reduced-motion: reduce) {
// 정지 상태에서도 「켜져 있고 접혀 있다」 는 결과는 보인다 — 움직임만 뺀다
.sa.is-demo .sa__fold,
.sa.is-demo .sa__sumfold,
.sa.is-demo .sa__tick::before,
.sa.is-demo .sa__tick svg,
.sa.is-demo .sa__field,
.sa.is-demo .sa__sumtext { animation: none !important; }
.sa.is-demo .sa__field,
.sa.is-demo .sa__sumtext { transform: none !important; }
.sa.is-demo .sa__tick svg { transform: scale(1) !important; opacity: 1 !important; }
.sa__fold { transition: none !important; }
.sa__sumfold { transition: none !important; }
.sa__tick::before { transition: none !important; }
.sa__tick svg { transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sa {
box-sizing: border-box;
width: min(300px, 92vw);
padding: 12px;
border: 1px solid rgba(23, 20, 26, 0.14);
border-radius: 16px;
background: #fff7e6;
color: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.sa * {
box-sizing: border-box;
}
.sa__who {
margin-bottom: 8px;
}
.sa__kicker {
margin: 0;
font-size: 11px;
font-weight: 700;
line-height: 1.3;
letter-spacing: 0.04em;
color: rgba(23, 20, 26, 0.72);
}
.sa__line {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
min-width: 0;
}
.sa__ic {
flex: 0 0 auto;
display: inline-grid;
place-items: center;
width: 14px;
height: 14px;
color: rgba(23, 20, 26, 0.72);
}
.sa__ic svg {
width: 14px;
height: 14px;
}
.sa__v {
font-size: 12px;
font-weight: 500;
line-height: 1.5;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sa__same {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
cursor: pointer;
}
.sa__box {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
opacity: 0;
pointer-events: none;
}
.sa__tick {
position: relative;
flex: 0 0 auto;
display: inline-grid;
place-items: center;
width: 18px;
height: 18px;
border: 2px solid rgba(23, 20, 26, 0.3);
border-radius: 4px;
color: #fff7e6;
}
.sa__tick::before {
content: "";
position: absolute;
inset: -2px;
border: 2px solid #2f6df6;
border-radius: 4px;
background: #2f6df6;
opacity: 0;
transition: opacity 140ms linear;
}
.sa__tick svg {
position: relative;
width: 12px;
height: 12px;
opacity: 0;
transform: scale(0.4);
transition: opacity 140ms linear, transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.sa:has(.sa__box:checked) .sa__tick::before {
opacity: 1;
}
.sa:has(.sa__box:checked) .sa__tick svg {
opacity: 1;
transform: scale(1);
}
.sa:has(.sa__box:focus-visible) .sa__tick {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.sa__label {
font-size: 12px;
font-weight: 700;
line-height: 1.3;
}
.sa__sumfold {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.sa:has(.sa__box:checked) .sa__sumfold {
grid-template-rows: 1fr;
}
.sa__sum {
min-height: 0;
margin: 0;
overflow: hidden;
}
.sa__sumtext {
display: block;
border-left: 2px solid #2f6df6;
padding-left: 8px;
font-size: 11px;
font-weight: 600;
line-height: 1.5;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sa__fold {
display: grid;
grid-template-rows: 1fr;
transition: grid-template-rows 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.sa:has(.sa__box:checked) .sa__fold {
grid-template-rows: 0fr;
}
.sa__inner {
display: grid;
gap: 4px;
min-height: 0;
overflow: hidden;
}
.sa__field {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
}
.sa__fk {
flex: 0 0 auto;
width: 48px;
font-size: 10px;
font-weight: 700;
line-height: 1.4;
color: rgba(23, 20, 26, 0.72);
}
.sa__fv {
flex: 1 1 auto;
min-width: 0;
height: 20px;
padding: 0 8px;
border: 1px solid rgba(23, 20, 26, 0.2);
border-radius: 4px;
background: #ffffff;
color: #17141a;
font-family: inherit;
font-size: 10px;
font-weight: 500;
line-height: 1.4;
}
.sa__fv:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 1px;
}
@keyframes sa-fields {
0%, 40% {
grid-template-rows: 0fr;
}
44%, 80% {
grid-template-rows: 1fr;
}
84%, 100% {
grid-template-rows: 0fr;
}
}
@keyframes sa-sumrow {
0%, 40% {
grid-template-rows: 1fr;
}
44%, 80% {
grid-template-rows: 0fr;
}
84%, 100% {
grid-template-rows: 1fr;
}
}
@keyframes sa-tickbg {
0%, 40% {
opacity: 1;
}
44%, 80% {
opacity: 0;
}
84%, 100% {
opacity: 1;
}
}
@keyframes sa-tickfade {
0%, 40% {
opacity: 1;
}
44%, 80% {
opacity: 0;
}
84%, 100% {
opacity: 1;
}
}
@keyframes sa-tickpop {
0%, 43% {
transform: scale(1);
}
44%, 84% {
transform: scale(0.3);
}
96%, 100% {
transform: scale(1);
}
}
@keyframes sa-rise {
0%, 43% {
transform: translateY(0);
}
44% {
transform: translateY(16px);
}
76%, 100% {
transform: translateY(0);
}
}
@keyframes sa-sumrise {
0%, 83% {
transform: translateX(0);
}
84% {
transform: translateX(14px);
}
100% {
transform: translateX(0);
}
}
.sa.is-demo .sa__fold,
.sa.is-demo .sa__sumfold,
.sa.is-demo .sa__tick::before {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.sa.is-demo .sa__fold {
animation-name: sa-fields;
}
.sa.is-demo .sa__sumfold {
animation-name: sa-sumrow;
}
.sa.is-demo .sa__tick::before {
animation-name: sa-tickbg;
}
.sa.is-demo .sa__tick svg {
animation-name: sa-tickfade, sa-tickpop;
animation-duration: 2s, 2s;
animation-timing-function: steps(1, end), ease-out;
animation-iteration-count: infinite, infinite;
animation-fill-mode: backwards, backwards;
}
.sa.is-demo .sa__field,
.sa.is-demo .sa__sumtext {
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.sa.is-demo .sa__field {
animation-name: sa-rise;
}
.sa.is-demo .sa__field:nth-child(2) {
animation-delay: 60ms;
}
.sa.is-demo .sa__field:nth-child(3) {
animation-delay: 120ms;
}
.sa.is-demo .sa__sumtext {
animation-name: sa-sumrise;
}
@media (max-width: 340px) {
.sa {
padding: 8px;
}
.sa__kicker {
font-size: 10px;
}
.sa__v {
font-size: 11px;
}
.sa__label {
font-size: 11px;
}
.sa__sumtext {
font-size: 10px;
}
.sa__fv {
height: 18px;
}
.sa__fk {
width: 44px;
}
}
@media (prefers-reduced-motion: reduce) {
.sa.is-demo .sa__fold,
.sa.is-demo .sa__sumfold,
.sa.is-demo .sa__tick::before,
.sa.is-demo .sa__tick svg,
.sa.is-demo .sa__field,
.sa.is-demo .sa__sumtext {
animation: none !important;
}
.sa.is-demo .sa__field,
.sa.is-demo .sa__sumtext {
transform: none !important;
}
.sa.is-demo .sa__tick svg {
transform: scale(1) !important;
opacity: 1 !important;
}
.sa__fold {
transition: none !important;
}
.sa__sumfold {
transition: none !important;
}
.sa__tick::before {
transition: none !important;
}
.sa__tick svg {
transition: none !important;
}
}// Same as orderer — 배송지 칸 묶음을 접고, 접힌 칸은 초점에서 뺀다(inert). 값 복사도 여기서
var sa = document.getElementById('sa');
var same = document.getElementById('saSame');
var fold = document.getElementById('saFold');
var who = sa.querySelector('.sa__who');
var whoName = document.getElementById('saWhoName');
var whoAddr = document.getElementById('saWhoAddr');
var sum = document.getElementById('saSum');
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();
document.getElementById('saTel').value = who.getAttribute('data-tel');
sum.textContent = whoName.textContent.trim() + ' · ' + whoAddr.textContent.trim();
}
}
same.addEventListener('change', syncShipping);import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ShipSameAddressCollapse.module.scss";
export type ShipSameAddressCollapseProps = {
kicker?: string;
ordererName?: string;
ordererAddress?: string;
ordererTel?: string;
sameLabel?: string;
nameLabel?: string;
addressLabel?: string;
telLabel?: string;
groupLabel?: string;
/** 격자 미리보기용 2초 자동 루프. 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// ambient 가 얇아 이벤트 타입을 못 쓴다 — 필요한 꼴만 로컬 타입으로 선언
type CheckChange = { target: { checked: boolean } };
type FieldChange = { target: { value: string } };
function UserIcon() {
return (
<svg viewBox="0 0 256 256" fill="currentColor"><path d="M192,96a64,64,0,1,1-64-64A64,64,0,0,1,192,96Z" opacity="0.2"/><path d="M230.92,212c-15.23-26.33-38.7-45.21-66.09-54.16a72,72,0,1,0-73.66,0C63.78,166.78,40.31,185.66,25.08,212a8,8,0,1,0,13.85,8c18.84-32.56,52.14-52,89.07-52s70.23,19.44,89.07,52a8,8,0,1,0,13.85-8ZM72,96a56,56,0,1,1,56,56A56.06,56.06,0,0,1,72,96Z"/></svg>
);
}
function HouseIcon() {
return (
<svg viewBox="0 0 256 256" fill="currentColor"><path d="M216,120v96H152V152H104v64H40V120a8,8,0,0,1,2.34-5.66l80-80a8,8,0,0,1,11.32,0l80,80A8,8,0,0,1,216,120Z" opacity="0.2"/><path d="M219.31,108.68l-80-80a16,16,0,0,0-22.62,0l-80,80A15.87,15.87,0,0,0,32,120v96a8,8,0,0,0,8,8h64a8,8,0,0,0,8-8V160h32v56a8,8,0,0,0,8,8h64a8,8,0,0,0,8-8V120A15.87,15.87,0,0,0,219.31,108.68ZM208,208H160V152a8,8,0,0,0-8-8H104a8,8,0,0,0-8,8v56H48V120l80-80,80,80Z"/></svg>
);
}
function CheckIcon() {
return (
<svg viewBox="0 0 256 256" fill="currentColor"><path d="M232,56V200a16,16,0,0,1-16,16H40a16,16,0,0,1-16-16V56A16,16,0,0,1,40,40H216A16,16,0,0,1,232,56Z" opacity="0.2"/><path d="M205.66,85.66l-96,96a8,8,0,0,1-11.32,0l-40-40a8,8,0,0,1,11.32-11.32L104,164.69l90.34-90.35a8,8,0,0,1,11.32,11.32Z"/></svg>
);
}
export default function ShipSameAddressCollapse({
kicker = "주문자",
ordererName = "김도윤",
ordererAddress = "서울 마포구 양화로 12, 3층",
ordererTel = "010-2345-6789",
sameLabel = "주문자와 같음",
nameLabel = "받는 분",
addressLabel = "주소",
telLabel = "연락처",
groupLabel = "배송지 입력",
demo = true,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: ShipSameAddressCollapseProps) {
const [same, setSame] = useState(true);
const [live, setLive] = useState(demo);
const [shipName, setShipName] = useState(ordererName);
const [shipAddress, setShipAddress] = useState(ordererAddress);
const [shipTel, setShipTel] = useState(ordererTel);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 주문자와 같음 — 값 복사는 여기서, 접힌 칸은 aria-hidden + inert 로 초점에서 뺀다
function onSame(e: CheckChange) {
const folded = e.target.checked;
setLive(false);
setSame(folded);
if (folded) {
setShipName(ordererName);
setShipAddress(ordererAddress);
setShipTel(ordererTel);
}
}
const cls = [styles.sa, live ? styles.isDemo : "", same ? styles.isSame : ""].filter(Boolean).join(" ");
const summary = `${ordererName} · ${ordererAddress}`;
return (
<section className={cls} style={vars} aria-label={groupLabel}>
<div className={styles.who}>
<p className={styles.kicker}>{kicker}</p>
<p className={styles.line}>
<span className={styles.ic} aria-hidden="true"><UserIcon /></span>
<b className={styles.v}>{ordererName}</b>
</p>
<p className={styles.line}>
<span className={styles.ic} aria-hidden="true"><HouseIcon /></span>
<span className={styles.v}>{ordererAddress}</span>
</p>
</div>
<label className={styles.same}>
<input className={styles.box} type="checkbox" checked={same} onChange={onSame} />
<span className={styles.tick} aria-hidden="true"><CheckIcon /></span>
<span className={styles.label}>{sameLabel}</span>
</label>
{/* 접혔을 때 남는 요약 한 줄 — 주문자 이름·주소가 이미 들어와 있다 */}
<div className={styles.sumfold}>
<p className={styles.sum}><b className={styles.sumtext}>{summary}</b></p>
</div>
<div className={styles.fold} aria-hidden={same} inert={same ? "" : undefined}>
<div className={styles.inner}>
<p className={styles.field}>
<span className={styles.fk}>{nameLabel}</span>
<input
className={styles.fv}
type="text"
value={shipName}
aria-label={nameLabel}
onChange={(e: FieldChange) => setShipName(e.target.value)}
/>
</p>
<p className={styles.field}>
<span className={styles.fk}>{addressLabel}</span>
<input
className={styles.fv}
type="text"
value={shipAddress}
aria-label={addressLabel}
onChange={(e: FieldChange) => setShipAddress(e.target.value)}
/>
</p>
<p className={styles.field}>
<span className={styles.fk}>{telLabel}</span>
<input
className={styles.fv}
type="tel"
value={shipTel}
aria-label={telLabel}
onChange={(e: FieldChange) => setShipTel(e.target.value)}
/>
</p>
</div>
</div>
</section>
);
}06 · Shipping fields that fold away and fill themselves in — Measured area 22.444% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
Use it on order forms that collect orderer and shipping details separately, or on billing address forms.
function syncShipping() {
sa.classList.remove('is-demo'); // a human touch ends the preview loop
var folded = same.checked;
fold.setAttribute('aria-hidden', folded ? 'true' : 'false');
fold.toggleAttribute('inert', folded); // folded cells are unreachable by Tab
if (folded) {
document.getElementById('saName').value = whoName.textContent.trim();
document.getElementById('saAddr').value = whoAddr.textContent.trim();
sum.textContent = whoName.textContent.trim() + ' · ' + whoAddr.textContent.trim();
}
}
07Arrival chip whose date rolls over when you change the shipping speed
What you see
Choose standard, express, or dawn delivery, and the date inside the arrival chip is pushed up and out while the new date rises into its place. The chip is an odometer — a strip pushed by translateY inside an overflow: hidden window, so no middle frame shows two overlapping dates — while an aria-live cell reads the new arrival date and fee as one sentence and the fee follows the picked speed, logging 9.291% changed area over 12 of 23 frames.

<main class="stage">
<section class="sp is-demo" id="sp" aria-label="Choose a shipping speed">
<header class="sp__head">
<span class="sp__ic" aria-hidden="true"><svg viewBox="0 0 256 256" fill="currentColor"><path d="M248,120v64a8,8,0,0,1-8,8H216a24,24,0,0,0-48,0H104a24,24,0,0,0-48,0H32a8,8,0,0,1-8-8V144H184V120Z" opacity="0.2"/><path d="M255.42,117l-14-35A15.93,15.93,0,0,0,226.58,72H192V64a8,8,0,0,0-8-8H32A16,16,0,0,0,16,72V184a16,16,0,0,0,16,16H49a32,32,0,0,0,62,0h50a32,32,0,0,0,62,0h17a16,16,0,0,0,16-16V120A7.94,7.94,0,0,0,255.42,117ZM192,88h34.58l9.6,24H192ZM32,72H176v64H32ZM80,208a16,16,0,1,1,16-16A16,16,0,0,1,80,208Zm81-24H111a32,32,0,0,0-62,0H32V152H176v12.31A32.11,32.11,0,0,0,161,184Zm31,24a16,16,0,1,1,16-16A16,16,0,0,1,192,208Zm48-24H223a32.06,32.06,0,0,0-31-24V128h48Z"/></svg></span>
<h2 class="sp__title">Shipping speed</h2>
</header>
<div class="sp__opts">
<span class="sp__thumb" id="sp-thumb" aria-hidden="true"></span>
<label class="sp__opt"><input class="sp__radio" type="radio" name="speed" value="0" checked><span class="sp__pill">Standard</span></label>
<label class="sp__opt"><input class="sp__radio" type="radio" name="speed" value="1"><span class="sp__pill">Express</span></label>
<label class="sp__opt"><input class="sp__radio" type="radio" name="speed" value="2"><span class="sp__pill">Dawn</span></label>
</div>
<p class="sp__line sp__eta">
<span class="sp__ic sp__ic--sm" aria-hidden="true"><svg viewBox="0 0 256 256" fill="currentColor"><path d="M224,128a96,96,0,1,1-96-96A96,96,0,0,1,224,128Z" opacity="0.2"/><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm64-88a8,8,0,0,1-8,8H128a8,8,0,0,1-8-8V72a8,8,0,0,1,16,0v48h48A8,8,0,0,1,192,128Z"/></svg></span>
<span class="sp__label">Arrives</span>
<span class="sp__win" aria-hidden="true"><span class="sp__roll">
<span class="sp__cell">Thu, Sep 17</span>
<span class="sp__cell">Mon, Sep 14</span>
<span class="sp__cell">Sun, Sep 13</span>
</span></span>
</p>
<p class="sp__line sp__line--fee">
<span class="sp__label">Shipping</span>
<span class="sp__win sp__win--fee" aria-hidden="true"><span class="sp__roll">
<span class="sp__cell sp__cell--fee">3,000 KRW</span>
<span class="sp__cell sp__cell--fee">5,000 KRW</span>
<span class="sp__cell sp__cell--fee">7,000 KRW</span>
</span></span>
</p>
<p class="sp__live" id="sp-live" aria-live="polite"></p>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 배송 방법을 고르면 도착 예정 날짜가 오도미터처럼 한 칸 밀려 바뀐다.
// 창(overflow: hidden)은 한 줄 높이만 열어 두고, 그 안의 줄판을 translateY 로 올린다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-ink;
@include stage($stage-yellow);
.sp {
position: relative;
box-sizing: border-box;
width: min(300px, 92vw);
padding: $sp-3;
border-radius: $r-card;
background: $subject-cream;
color: $color;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.sp * { box-sizing: border-box; }
.sp__head {
display: flex;
align-items: center;
gap: $sp-2;
margin: 0 0 $sp-3;
}
.sp__ic {
display: inline-grid;
place-items: center;
width: 20px;
height: 20px;
color: $color;
}
.sp__ic--sm { width: 14px; height: 14px; }
.sp__ic svg { width: 100%; height: 100%; }
.sp__title {
margin: 0;
font-size: 13px;
font-weight: 700;
line-height: 1.3;
}
// ── 배송 방법 세 칸: 균등 3열 격자 위를 같은 폭의 알약이 옮겨 다닌다 ──
// 칸 폭을 격자가 정하므로 알약은 translateX(100%) 한 번이 정확히 한 칸이다 (짐작한 px 이 아니다)
.sp__opts {
position: relative;
display: grid;
grid-template-columns: repeat(3, 1fr);
padding: 0;
border-radius: $r-pill;
background: rgba(23, 20, 26, .08);
}
.sp__thumb {
position: absolute;
top: 0;
left: 0;
width: calc(100% / 3);
height: 100%;
border: 2px solid $color;
border-radius: $r-pill;
background: $subject-cream;
transform: translateX(calc(var(--i, 0) * 100%));
transition: transform $dur-base $easing;
}
.sp__opt {
position: relative;
z-index: 1;
display: block;
cursor: pointer;
}
.sp__radio {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
opacity: 0;
}
.sp__pill {
display: block;
padding: $sp-1 0;
border-radius: $r-pill;
text-align: center;
font-size: 11px;
font-weight: 700;
line-height: 1.4;
}
.sp__radio:focus-visible ~ .sp__pill {
outline: 2px solid $color;
outline-offset: 2px;
}
// ── 도착 예정 · 배송비 줄 ──
.sp__line {
display: flex;
align-items: center;
gap: $sp-2;
margin: $sp-3 0 0;
}
// 도착 예정 줄은 칩이다 — 빠른 배송일수록 바탕이 진해져, 날짜가 자리를 잡은 순간이
// 한 프레임 안에서 가장 크게 달라진다(= 대표 이미지가 밀리는 중간이 아니라 멎은 장면이 된다)
.sp__eta {
padding: $sp-1 $sp-2;
border-radius: $r-control;
background-color: rgba(23, 20, 26, .06);
transition: background-color $dur-quick steps(1, end);
}
.sp:has(.sp__opt:nth-child(3) .sp__radio:checked) .sp__eta { background-color: rgba(47, 109, 246, .14); }
.sp:has(.sp__opt:nth-child(4) .sp__radio:checked) .sp__eta { background-color: rgba(47, 109, 246, .24); }
.sp__line--fee { margin-top: $sp-2; }
.sp__label {
font-size: 11px;
font-weight: 700;
line-height: 1.4;
color: rgba(23, 20, 26, .62);
}
// 창은 한 줄만 보여 준다 — 줄판이 위로 밀려 나가고 다음 줄이 아래에서 올라온다
.sp__win {
margin-left: auto;
height: 16px;
overflow: hidden;
}
.sp__roll {
display: block;
transform: translateY(calc(var(--i, 0) * -33.3333%));
transition: transform $dur-base $easing;
}
.sp__cell {
display: block;
height: 16px;
font-size: 13px;
font-weight: 700;
line-height: 16px;
text-align: right;
white-space: nowrap;
}
.sp__cell--fee {
font-size: 12px;
color: rgba(23, 20, 26, .72);
}
// 화면에는 안 보이되 자리는 지운다 — 바뀐 결과를 읽어 주는 칸
.sp__live {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
// ── 격자 미리보기 루프: 2초 안에 일반 → 빠른 → 새벽 → 일반 ──
// 되돌아오는 구간은 88% 에 끝난다 (24프레임의 마지막 장이 95.8% 라 그 뒤는 안 찍힌다)
@keyframes spRoll {
0%, 20% { transform: translateY(0); }
32%, 48% { transform: translateY(-33.3333%); }
60%, 76% { transform: translateY(-66.6666%); }
88%, 100% { transform: translateY(0); }
}
@keyframes spThumb {
0%, 20% { transform: translateX(0); }
32%, 48% { transform: translateX(100%); }
60%, 76% { transform: translateX(200%); }
88%, 100% { transform: translateX(0); }
}
// 칩 바탕은 날짜가 멎는 32% · 60% · 88% 에 정확히 한 프레임으로 갈린다 (steps 라 중간색이 없다)
@keyframes spTint {
0%, 20% { background-color: rgba(23, 20, 26, .06); }
32%, 48% { background-color: rgba(47, 109, 246, .14); }
60%, 76% { background-color: rgba(47, 109, 246, .24); }
88%, 100% { background-color: rgba(23, 20, 26, .06); }
}
.sp.is-demo {
.sp__eta {
animation-name: spTint;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.sp__roll {
animation-name: spRoll;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.sp__thumb {
animation-name: spThumb;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
}
@media (max-width: 340px) {
.sp { padding: $sp-2; }
.sp__head { margin-bottom: $sp-2; }
.sp__line { margin-top: $sp-2; }
.sp__title { font-size: 12px; }
.sp__cell { font-size: 12px; }
}
@media (prefers-reduced-motion: reduce) {
.sp__roll,
.sp__thumb,
.sp__eta { transition: none !important; }
.sp.is-demo .sp__roll,
.sp.is-demo .sp__thumb,
.sp.is-demo .sp__eta { animation: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sp {
position: relative;
box-sizing: border-box;
width: min(300px, 92vw);
padding: 12px;
border-radius: 16px;
background: #fff7e6;
color: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.sp * {
box-sizing: border-box;
}
.sp__head {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 12px;
}
.sp__ic {
display: inline-grid;
place-items: center;
width: 20px;
height: 20px;
color: #17141a;
}
.sp__ic--sm {
width: 14px;
height: 14px;
}
.sp__ic svg {
width: 100%;
height: 100%;
}
.sp__title {
margin: 0;
font-size: 13px;
font-weight: 700;
line-height: 1.3;
}
.sp__opts {
position: relative;
display: grid;
grid-template-columns: repeat(3, 1fr);
padding: 0;
border-radius: 999px;
background: rgba(23, 20, 26, 0.08);
}
.sp__thumb {
position: absolute;
top: 0;
left: 0;
width: 33.3333333333%;
height: 100%;
border: 2px solid #17141a;
border-radius: 999px;
background: #fff7e6;
transform: translateX(calc(var(--i, 0) * 100%));
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.sp__opt {
position: relative;
z-index: 1;
display: block;
cursor: pointer;
}
.sp__radio {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
opacity: 0;
}
.sp__pill {
display: block;
padding: 4px 0;
border-radius: 999px;
text-align: center;
font-size: 11px;
font-weight: 700;
line-height: 1.4;
}
.sp__radio:focus-visible ~ .sp__pill {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.sp__line {
display: flex;
align-items: center;
gap: 8px;
margin: 12px 0 0;
}
.sp__eta {
padding: 4px 8px;
border-radius: 12px;
background-color: rgba(23, 20, 26, 0.06);
transition: background-color 220ms steps(1, end);
}
.sp:has(.sp__opt:nth-child(3) .sp__radio:checked) .sp__eta {
background-color: rgba(47, 109, 246, 0.14);
}
.sp:has(.sp__opt:nth-child(4) .sp__radio:checked) .sp__eta {
background-color: rgba(47, 109, 246, 0.24);
}
.sp__line--fee {
margin-top: 8px;
}
.sp__label {
font-size: 11px;
font-weight: 700;
line-height: 1.4;
color: rgba(23, 20, 26, 0.62);
}
.sp__win {
margin-left: auto;
height: 16px;
overflow: hidden;
}
.sp__roll {
display: block;
transform: translateY(calc(var(--i, 0) * -33.3333%));
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.sp__cell {
display: block;
height: 16px;
font-size: 13px;
font-weight: 700;
line-height: 16px;
text-align: right;
white-space: nowrap;
}
.sp__cell--fee {
font-size: 12px;
color: rgba(23, 20, 26, 0.72);
}
.sp__live {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
@keyframes spRoll {
0%, 20% {
transform: translateY(0);
}
32%, 48% {
transform: translateY(-33.3333%);
}
60%, 76% {
transform: translateY(-66.6666%);
}
88%, 100% {
transform: translateY(0);
}
}
@keyframes spThumb {
0%, 20% {
transform: translateX(0);
}
32%, 48% {
transform: translateX(100%);
}
60%, 76% {
transform: translateX(200%);
}
88%, 100% {
transform: translateX(0);
}
}
@keyframes spTint {
0%, 20% {
background-color: rgba(23, 20, 26, 0.06);
}
32%, 48% {
background-color: rgba(47, 109, 246, 0.14);
}
60%, 76% {
background-color: rgba(47, 109, 246, 0.24);
}
88%, 100% {
background-color: rgba(23, 20, 26, 0.06);
}
}
.sp.is-demo .sp__eta {
animation-name: spTint;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.sp.is-demo .sp__roll {
animation-name: spRoll;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.sp.is-demo .sp__thumb {
animation-name: spThumb;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@media (max-width: 340px) {
.sp {
padding: 8px;
}
.sp__head {
margin-bottom: 8px;
}
.sp__line {
margin-top: 8px;
}
.sp__title {
font-size: 12px;
}
.sp__cell {
font-size: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.sp__roll,
.sp__thumb,
.sp__eta {
transition: none !important;
}
.sp.is-demo .sp__roll,
.sp.is-demo .sp__thumb,
.sp.is-demo .sp__eta {
animation: none !important;
}
}// 배송 방법을 고르면 --i 하나가 바뀌고, 도착 칩과 Shipping 칸이 같은 값으로 밀려 올라간다.
// 칸 안의 글자는 aria-hidden 이라 읽히지 않는다 — 바뀐 결과는 aria-live 칸이 한 문장으로 읽어 준다.
var sp = document.getElementById('sp');
var live = document.getElementById('sp-live');
var summary = [
'Arrives Thu, Sep 17 · shipping 3,000 KRW',
'Arrives Mon, Sep 14 · shipping 5,000 KRW',
'Arrives Sun, Sep 13 · shipping 7,000 KRW'
];
function pick(index) {
sp.classList.remove('is-demo');
sp.style.setProperty('--i', index);
live.textContent = summary[index];
}
var radios = sp.querySelectorAll('.sp__radio');
for (var n = 0; n < radios.length; n++) {
radios[n].addEventListener('change', function (event) {
pick(Number(event.target.value));
});
}import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ShipSpeedEtaSwap.module.scss";
export type ShipSpeedEtaSwapProps = {
title?: string;
/** 배송 방법 이름 — 도착 예정일·배송비와 같은 순서 */
speeds?: string[];
dates?: string[];
fees?: string[];
/** 고른 결과를 한 문장으로 읽어 주는 문구 (aria-live) */
summaries?: string[];
etaLabel?: string;
feeLabel?: string;
groupLabel?: string;
/** 격자 미리보기용 2초 자동 루프. 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
"--i"?: number;
};
// ambient 가 얇아 DOM 이벤트 타입을 못 쓴다 — 필요한 꼴만 로컬 타입으로 선언
type ChangeEvt = { target: { value: string } };
function TruckIcon() {
return (
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor" aria-hidden="true"><path d="M248,120v64a8,8,0,0,1-8,8H216a24,24,0,0,0-48,0H104a24,24,0,0,0-48,0H32a8,8,0,0,1-8-8V144H184V120Z" opacity="0.2"/><path d="M255.42,117l-14-35A15.93,15.93,0,0,0,226.58,72H192V64a8,8,0,0,0-8-8H32A16,16,0,0,0,16,72V184a16,16,0,0,0,16,16H49a32,32,0,0,0,62,0h50a32,32,0,0,0,62,0h17a16,16,0,0,0,16-16V120A7.94,7.94,0,0,0,255.42,117ZM192,88h34.58l9.6,24H192ZM32,72H176v64H32ZM80,208a16,16,0,1,1,16-16A16,16,0,0,1,80,208Zm81-24H111a32,32,0,0,0-62,0H32V152H176v12.31A32.11,32.11,0,0,0,161,184Zm31,24a16,16,0,1,1,16-16A16,16,0,0,1,192,208Zm48-24H223a32.06,32.06,0,0,0-31-24V128h48Z"/></svg>
);
}
function ClockIcon() {
return (
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor" aria-hidden="true"><path d="M224,128a96,96,0,1,1-96-96A96,96,0,0,1,224,128Z" opacity="0.2"/><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm64-88a8,8,0,0,1-8,8H128a8,8,0,0,1-8-8V72a8,8,0,0,1,16,0v48h48A8,8,0,0,1,192,128Z"/></svg>
);
}
export default function ShipSpeedEtaSwap({
title = "배송 방법",
speeds = ["일반", "빠른", "새벽"],
dates = ["9월 17일 (목)", "9월 14일 (월)", "9월 13일 (일)"],
fees = ["3,000원", "5,000원", "7,000원"],
summaries = [
"9월 17일 (목) 도착 · 배송비 3,000원",
"9월 14일 (월) 도착 · 배송비 5,000원",
"9월 13일 (일) 도착 · 배송비 7,000원",
],
etaLabel = "도착 예정",
feeLabel = "배송비",
groupLabel = "배송 방법 고르기",
demo = true,
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#17141a",
}: ShipSpeedEtaSwapProps) {
const [index, setIndex] = useState(0);
const [live, setLive] = useState(demo);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color, "--i": index };
const cls = [styles.sp, live ? styles.isDemo : ""].filter(Boolean).join(" ");
// 빠를수록 도착 칩 바탕이 진해진다 — 날짜가 자리를 잡은 순간이 한눈에 보이게
const tint = index === 1 ? styles.tint1 : index === 2 ? styles.tint2 : "";
function pick(event: ChangeEvt) {
setLive(false);
setIndex(Number(event.target.value));
}
return (
<section className={cls} style={vars} aria-label={groupLabel}>
<header className={styles.head}>
<span className={styles.ic} aria-hidden="true"><TruckIcon /></span>
<h2 className={styles.title}>{title}</h2>
</header>
<div className={styles.opts}>
<span className={styles.thumb} aria-hidden="true" />
{speeds.map((name, n) => (
<label className={styles.opt} key={name}>
<input
className={styles.radio}
type="radio"
name="speed"
value={String(n)}
checked={index === n}
onChange={pick}
/>
<span className={styles.pill}>{name}</span>
</label>
))}
</div>
<p className={`${styles.line} ${styles.eta} ${tint}`}>
<span className={`${styles.ic} ${styles.icSm}`} aria-hidden="true"><ClockIcon /></span>
<span className={styles.label}>{etaLabel}</span>
<span className={styles.win} aria-hidden="true">
<span className={styles.roll}>
{dates.map((date) => <span className={styles.cell} key={date}>{date}</span>)}
</span>
</span>
</p>
<p className={`${styles.line} ${styles.lineFee}`}>
<span className={styles.label}>{feeLabel}</span>
<span className={styles.win} aria-hidden="true">
<span className={styles.roll}>
{fees.map((fee) => <span className={`${styles.cell} ${styles.cellFee}`} key={fee}>{fee}</span>)}
</span>
</span>
</p>
<p className={styles.srOnly} aria-live="polite">{live ? "" : summaries[index]}</p>
</section>
);
}07 · Arrival chip whose date slides over when you change the shipping speed — Measured area 9.291% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
Use it for per-speed arrival estimates or pickup time selection.
// The window shows one row only — the old row is pushed up, the next rises from below
.sp__win {
height: 16px;
overflow: hidden;
}
.sp__roll {
display: block;
transform: translateY(calc(var(--i, 0) * -33.3333%));
transition: transform $dur-base $easing;
}
08Agree-to-all switch that lights the items below in turn
What happens on screen
Turning on "agree to all" draws the four check marks below it, one after another, and switching any single item off quietly clears only the master switch. Each check is an SVG polyline drawn by stroke-dashoffset: 1 → 0, the cascade is one line of transition-delay, and the in-between state of "some are on" lives on the switch's indeterminate property, with 1.8% changed area at intensity 122.3 over 19 of 23 frames.

<main class="stage">
<section class="ta is-demo" id="ta" aria-label="Terms agreement block">
<div class="ta__all">
<span class="ta__ic" aria-hidden="true"><svg viewBox="0 0 256 256" fill="currentColor"><path d="M216,48V208a8,8,0,0,1-8,8H48a8,8,0,0,1-8-8V48a8,8,0,0,1,8-8H208A8,8,0,0,1,216,48Z" opacity="0.2"/><path d="M173.66,98.34a8,8,0,0,1,0,11.32l-56,56a8,8,0,0,1-11.32,0l-24-24a8,8,0,0,1,11.32-11.32L112,148.69l50.34-50.35A8,8,0,0,1,173.66,98.34ZM224,48V208a16,16,0,0,1-16,16H48a16,16,0,0,1-16-16V48A16,16,0,0,1,48,32H208A16,16,0,0,1,224,48ZM208,208V48H48V208H208Z"/></svg></span>
<label class="ta__allText" for="ta-all">Agree to all</label>
<input class="ta__switch" type="checkbox" id="ta-all">
</div>
<ul class="ta__list">
<li class="ta__row">
<input class="ta__box" type="checkbox" id="ta-1">
<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>
<label class="ta__text" for="ta-1">Terms of service (required)</label>
</li>
<li class="ta__row">
<input class="ta__box" type="checkbox" id="ta-2">
<label class="ta__mark" for="ta-2" 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>
<label class="ta__text" for="ta-2">Personal data collection (required)</label>
</li>
<li class="ta__row">
<input class="ta__box" type="checkbox" id="ta-3">
<label class="ta__mark" for="ta-3" 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>
<label class="ta__text" for="ta-3">Payment provider terms (required)</label>
</li>
<li class="ta__row">
<input class="ta__box" type="checkbox" id="ta-4">
<label class="ta__mark" for="ta-4" 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>
<label class="ta__text" for="ta-4">Marketing emails (optional)</label>
</li>
</ul>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 전체 동의를 켜면 아래 네 칸의 체크 표시가 stroke-dashoffset 으로 차례차례 그려진다.
// 시차는 animation-delay 가 주고, 지연 구간을 채우는 건 animation-fill-mode: backwards 다
// — 없으면 첫 프레임에 네 개가 다 그려진 채로 한 번 번쩍인다.
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-ink;
@include stage($stage-orange);
.ta {
box-sizing: border-box;
width: min(300px, 92vw);
padding: $sp-3;
border-radius: $r-card;
background: $subject-cream;
color: $color;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.ta * { box-sizing: border-box; }
// ── 전체 동의 줄 ──
.ta__all {
display: flex;
align-items: center;
gap: $sp-2;
padding: 0 0 $sp-3;
border-bottom: 1px solid rgba(23, 20, 26, .12);
}
.ta__ic {
display: inline-grid;
place-items: center;
width: 20px;
height: 20px;
color: $color;
}
.ta__ic svg { width: 100%; height: 100%; }
.ta__allText {
margin-right: auto;
font-size: 13px;
font-weight: 700;
line-height: 1.3;
cursor: pointer;
}
.ta__switch {
position: relative;
flex: 0 0 auto;
width: 30px;
height: 16px;
margin: 0;
padding: 0;
border: 0;
border-radius: $r-pill;
background-color: rgba(23, 20, 26, .2);
appearance: none;
-webkit-appearance: none;
cursor: pointer;
transition: background-color $dur-quick $easing;
&::after {
content: "";
position: absolute;
top: 2px;
left: 2px;
width: 12px;
height: 12px;
border-radius: 50%;
background: $subject-cream;
transition: transform $dur-quick $ease-spring;
}
&:checked { background-color: $color; }
&:checked::after { transform: translateX(14px); }
// 일부만 켜진 상태 — 전체 동의는 풀렸지만 아래는 남아 있다
&:indeterminate { background-color: rgba(23, 20, 26, .45); }
&:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
}
// ── 약관 네 줄 ──
.ta__list {
display: grid;
gap: $sp-2;
margin: $sp-3 0 0;
padding: 0;
list-style: none;
}
.ta__row {
position: relative;
display: flex;
align-items: center;
gap: $sp-2;
}
.ta__row:nth-child(1) { --i: 0; }
.ta__row:nth-child(2) { --i: 1; }
.ta__row:nth-child(3) { --i: 2; }
.ta__row:nth-child(4) { --i: 3; }
.ta__box {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
opacity: 0;
}
.ta__mark {
position: relative;
flex: 0 0 auto;
display: inline-grid;
place-items: center;
width: 22px;
height: 22px;
border: 2px solid rgba(23, 20, 26, .26);
border-radius: $r-xs;
color: $subject-cream;
cursor: pointer;
// 칸이 차는 것은 scale — 켤 때는 한 프레임에 차고, 끌 때는 천천히 줄어든다.
// 투명도가 아니라 크기라서 중간 프레임에 반투명 유령이 안 생긴다
&::before {
content: "";
position: absolute;
inset: -2px;
border-radius: inherit;
background-color: $color;
transform: scale(0);
transition: transform $dur-quick $easing;
transition-delay: calc(var(--i, 0) * 60ms);
}
}
.ta__mark svg {
position: relative;
z-index: 1;
width: 14px;
height: 14px;
}
// 체크는 폴리라인 한 줄이다 — 길이를 1 로 정규화해 두면 dashoffset 1 → 0 이 곧 「왼쪽부터 그린다」
.ta__tick {
stroke-dasharray: 1;
stroke-dashoffset: 1;
transition: stroke-dashoffset $dur-base $easing;
transition-delay: calc(var(--i, 0) * 60ms);
}
.ta__box:checked ~ .ta__mark::before { transform: scale(1); }
.ta__box:checked ~ .ta__mark .ta__tick { stroke-dashoffset: 0; }
.ta__box:focus-visible ~ .ta__mark {
outline: 2px solid $color;
outline-offset: 2px;
}
.ta__text {
font-size: 11px;
font-weight: 500;
line-height: 1.4;
cursor: pointer;
}
// ── 격자 미리보기 루프 ──
// 켜짐(6% 부터 60ms 씩)은 한 프레임에 톡, 꺼짐(46~80%)은 여덟 프레임에 걸쳐 천천히.
// 그래야 한 프레임에 가장 크게 달라지는 장면이 「차례로 켜지는 중」 이지 「꺼지는 중」 이 아니다.
// 구간마다 다른 이징이 필요하므로 타이밍 함수를 키프레임 안에 적는다
@keyframes taFill {
0% { transform: scale(0); animation-timing-function: steps(1, end); }
6% { transform: scale(0); animation-timing-function: $ease-pop; }
10% { transform: scale(1); animation-timing-function: steps(1, end); }
46% { transform: scale(1); animation-timing-function: linear; }
80%, 100% { transform: scale(0); }
}
@keyframes taDraw {
0%, 10% { stroke-dashoffset: 1; }
24%, 46% { stroke-dashoffset: 0; }
66%, 100% { stroke-dashoffset: 1; }
}
@keyframes taSwitchOn {
0% { background-color: rgba(23, 20, 26, .2); }
2%, 70% { background-color: $color; }
84%, 100% { background-color: rgba(23, 20, 26, .2); }
}
@keyframes taThumb {
0%, 2% { transform: translateX(0); }
10%, 70% { transform: translateX(14px); }
80%, 100% { transform: translateX(0); }
}
.ta.is-demo {
.ta__switch {
animation-name: taSwitchOn;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ta__switch::after {
animation-name: taThumb;
animation-duration: $duration;
animation-timing-function: $ease-spring;
animation-iteration-count: infinite;
}
.ta__mark::before {
animation-name: taFill;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
animation-delay: calc(var(--i, 0) * 60ms);
}
.ta__tick {
animation-name: taDraw;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
animation-delay: calc(var(--i, 0) * 60ms);
}
}
@media (max-width: 340px) {
.ta { padding: $sp-2; }
.ta__all { padding-bottom: $sp-2; }
.ta__list { gap: $sp-1; margin-top: $sp-2; }
.ta__allText { font-size: 12px; }
}
@media (prefers-reduced-motion: reduce) {
.ta__switch,
.ta__switch::after,
.ta__tick,
.ta__mark::before { transition: none !important; }
.ta.is-demo .ta__switch,
.ta.is-demo .ta__switch::after,
.ta.is-demo .ta__mark::before,
.ta.is-demo .ta__tick { animation: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ta {
box-sizing: border-box;
width: min(300px, 92vw);
padding: 12px;
border-radius: 16px;
background: #fff7e6;
color: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.ta * {
box-sizing: border-box;
}
.ta__all {
display: flex;
align-items: center;
gap: 8px;
padding: 0 0 12px;
border-bottom: 1px solid rgba(23, 20, 26, 0.12);
}
.ta__ic {
display: inline-grid;
place-items: center;
width: 20px;
height: 20px;
color: #17141a;
}
.ta__ic svg {
width: 100%;
height: 100%;
}
.ta__allText {
margin-right: auto;
font-size: 13px;
font-weight: 700;
line-height: 1.3;
cursor: pointer;
}
.ta__switch {
position: relative;
flex: 0 0 auto;
width: 30px;
height: 16px;
margin: 0;
padding: 0;
border: 0;
border-radius: 999px;
background-color: rgba(23, 20, 26, 0.2);
appearance: none;
-webkit-appearance: none;
cursor: pointer;
transition: background-color 220ms ease-out;
}
.ta__switch::after {
content: "";
position: absolute;
top: 2px;
left: 2px;
width: 12px;
height: 12px;
border-radius: 50%;
background: #fff7e6;
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ta__switch:checked {
background-color: #17141a;
}
.ta__switch:checked::after {
transform: translateX(14px);
}
.ta__switch:indeterminate {
background-color: rgba(23, 20, 26, 0.45);
}
.ta__switch:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.ta__list {
display: grid;
gap: 8px;
margin: 12px 0 0;
padding: 0;
list-style: none;
}
.ta__row {
position: relative;
display: flex;
align-items: center;
gap: 8px;
}
.ta__row:nth-child(1) {
--i: 0;
}
.ta__row:nth-child(2) {
--i: 1;
}
.ta__row:nth-child(3) {
--i: 2;
}
.ta__row:nth-child(4) {
--i: 3;
}
.ta__box {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
opacity: 0;
}
.ta__mark {
position: relative;
flex: 0 0 auto;
display: inline-grid;
place-items: center;
width: 22px;
height: 22px;
border: 2px solid rgba(23, 20, 26, 0.26);
border-radius: 4px;
color: #fff7e6;
cursor: pointer;
}
.ta__mark::before {
content: "";
position: absolute;
inset: -2px;
border-radius: inherit;
background-color: #17141a;
transform: scale(0);
transition: transform 220ms ease-out;
transition-delay: calc(var(--i, 0) * 60ms);
}
.ta__mark svg {
position: relative;
z-index: 1;
width: 14px;
height: 14px;
}
.ta__tick {
stroke-dasharray: 1;
stroke-dashoffset: 1;
transition: stroke-dashoffset 300ms ease-out;
transition-delay: calc(var(--i, 0) * 60ms);
}
.ta__box:checked ~ .ta__mark::before {
transform: scale(1);
}
.ta__box:checked ~ .ta__mark .ta__tick {
stroke-dashoffset: 0;
}
.ta__box:focus-visible ~ .ta__mark {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.ta__text {
font-size: 11px;
font-weight: 500;
line-height: 1.4;
cursor: pointer;
}
@keyframes taFill {
0% {
transform: scale(0);
animation-timing-function: steps(1, end);
}
6% {
transform: scale(0);
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
}
10% {
transform: scale(1);
animation-timing-function: steps(1, end);
}
46% {
transform: scale(1);
animation-timing-function: linear;
}
80%, 100% {
transform: scale(0);
}
}
@keyframes taDraw {
0%, 10% {
stroke-dashoffset: 1;
}
24%, 46% {
stroke-dashoffset: 0;
}
66%, 100% {
stroke-dashoffset: 1;
}
}
@keyframes taSwitchOn {
0% {
background-color: rgba(23, 20, 26, 0.2);
}
2%, 70% {
background-color: #17141a;
}
84%, 100% {
background-color: rgba(23, 20, 26, 0.2);
}
}
@keyframes taThumb {
0%, 2% {
transform: translateX(0);
}
10%, 70% {
transform: translateX(14px);
}
80%, 100% {
transform: translateX(0);
}
}
.ta.is-demo .ta__switch {
animation-name: taSwitchOn;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ta.is-demo .ta__switch::after {
animation-name: taThumb;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.ta.is-demo .ta__mark::before {
animation-name: taFill;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
animation-delay: calc(var(--i, 0) * 60ms);
}
.ta.is-demo .ta__tick {
animation-name: taDraw;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
animation-delay: calc(var(--i, 0) * 60ms);
}
@media (max-width: 340px) {
.ta {
padding: 8px;
}
.ta__all {
padding-bottom: 8px;
}
.ta__list {
gap: 4px;
margin-top: 8px;
}
.ta__allText {
font-size: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.ta__switch,
.ta__switch::after,
.ta__tick,
.ta__mark::before {
transition: none !important;
}
.ta.is-demo .ta__switch,
.ta.is-demo .ta__switch::after,
.ta.is-demo .ta__mark::before,
.ta.is-demo .ta__tick {
animation: none !important;
}
}// 전체 동의는 아래 네 칸을 한꺼번에 켜고, 한 칸만 꺼지면 자기만 조용히 풀린다.
// 「몇 개는 켜져 있다」 는 상태는 indeterminate 가 그대로 갖고 있다 — 별도 클래스가 필요 없다.
var ta = document.getElementById('ta');
var master = document.getElementById('ta-all');
var boxes = ta.querySelectorAll('.ta__box');
function sync() {
var on = 0;
for (var i = 0; i < boxes.length; i++) { if (boxes[i].checked) { on++; } }
master.checked = on === boxes.length;
master.indeterminate = on > 0 && on < boxes.length;
}
master.addEventListener('change', function () {
ta.classList.remove('is-demo');
for (var i = 0; i < boxes.length; i++) { boxes[i].checked = master.checked; }
master.indeterminate = false;
});
for (var n = 0; n < boxes.length; n++) {
boxes[n].addEventListener('change', function () {
ta.classList.remove('is-demo');
sync();
});
}import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./TermsAgreeAllCascade.module.scss";
export type TermsAgreeAllCascadeProps = {
allLabel?: string;
items?: string[];
groupLabel?: string;
/** 격자 미리보기용 2초 자동 루프. 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type RowVars = CSSProperties & { "--i"?: number };
// ambient 가 얇아 DOM 요소 타입을 못 쓴다 — 필요한 꼴만 로컬 타입으로 선언
type Toggle = { indeterminate: boolean };
function CheckSquareIcon() {
return (
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor" aria-hidden="true"><path d="M216,48V208a8,8,0,0,1-8,8H48a8,8,0,0,1-8-8V48a8,8,0,0,1,8-8H208A8,8,0,0,1,216,48Z" opacity="0.2"/><path d="M173.66,98.34a8,8,0,0,1,0,11.32l-56,56a8,8,0,0,1-11.32,0l-24-24a8,8,0,0,1,11.32-11.32L112,148.69l50.34-50.35A8,8,0,0,1,173.66,98.34ZM224,48V208a16,16,0,0,1-16,16H48a16,16,0,0,1-16-16V48A16,16,0,0,1,48,32H208A16,16,0,0,1,224,48ZM208,208V48H48V208H208Z"/></svg>
);
}
export default function TermsAgreeAllCascade({
allLabel = "전체 동의",
items = [
"이용약관 동의 (필수)",
"개인정보 수집 (필수)",
"결제 대행 서비스 (필수)",
"마케팅 정보 수신 (선택)",
],
groupLabel = "약관 동의 묶음",
demo = true,
duration = 2000,
easing = "ease-out",
color = "#17141a",
}: TermsAgreeAllCascadeProps) {
const [checked, setChecked] = useState<boolean[]>(items.map(() => false));
const [live, setLive] = useState(demo);
const masterRef = useRef<Toggle>(null);
const on = checked.filter(Boolean).length;
const all = on === items.length && on > 0;
// 「몇 개는 켜져 있다」 는 상태는 속성으로만 있다 — 렌더 뒤에 직접 놓는다
useEffect(() => {
const el = masterRef.current;
if (!el) return;
el.indeterminate = on > 0 && on < items.length;
}, [on, items.length]);
function toggleAll(next: boolean) {
setLive(false);
setChecked(items.map(() => next));
}
function toggleOne(index: number, next: boolean) {
setLive(false);
setChecked((prev) => prev.map((was, n) => (n === index ? next : was)));
}
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const cls = [styles.ta, live ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<section className={cls} style={vars} aria-label={groupLabel}>
<div className={styles.all}>
<span className={styles.ic} aria-hidden="true"><CheckSquareIcon /></span>
<label className={styles.allText} htmlFor="ta-all">{allLabel}</label>
<input
className={styles.switch}
type="checkbox"
id="ta-all"
ref={masterRef}
checked={all}
onChange={(event: { target: { checked: boolean } }) => toggleAll(event.target.checked)}
/>
</div>
<ul className={styles.list}>
{items.map((text, n) => {
const rowVars: RowVars = { "--i": n };
const id = `ta-${n + 1}`;
return (
<li className={styles.row} style={rowVars} key={text}>
<input
className={styles.box}
type="checkbox"
id={id}
checked={checked[n]}
onChange={(event: { target: { checked: boolean } }) => toggleOne(n, event.target.checked)}
/>
<label className={styles.mark} htmlFor={id} aria-hidden="true">
<svg viewBox="0 0 24 24" aria-hidden="true">
<polyline
className={styles.tick}
points="5 12.5 10 17 19 7"
pathLength="1"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</label>
<label className={styles.text} htmlFor={id}>{text}</label>
</li>
);
})}
</ul>
</section>
);
}08 · Agree-to-all switch that lights the items below in turn — Measured area 1.8% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
Use it for terms blocks before payment or consent groups at signup.
<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; }
09Bottom pay bar that steps aside once the summary is on screen
What moves
The total-and-pay bar pinned to the bottom drops away the moment the order summary enters the viewport, and climbs back when the summary leaves. An IntersectionObserver decides visibility, the bar sits outside the scroll box with position: absolute so it still stands at scroll zero, and the summary card fills the seat the bar left so the poster is never an empty screen, logging 25.205% changed area at intensity 85.2 over 16 of 23 frames.

<main class="stage">
<section class="co is-demo" id="co" aria-label="Order form and bottom pay bar">
<div class="co__view" id="co-view" tabindex="0" role="region" aria-label="Order form">
<div class="co__pad">
<p class="co__title">Order form</p>
<div class="co__item">
<span class="co__thumb" aria-hidden="true"></span>
<span class="co__name">Mug ×2</span>
<span class="co__price">24,000 KRW</span>
</div>
<div class="co__item">
<span class="co__thumb" aria-hidden="true"></span>
<span class="co__name">T-shirt ×1</span>
<span class="co__price">19,000 KRW</span>
</div>
<div class="co__item">
<span class="co__thumb" aria-hidden="true"></span>
<span class="co__name">Tote bag ×1</span>
<span class="co__price">9,000 KRW</span>
</div>
<div class="co__item">
<span class="co__thumb" aria-hidden="true"></span>
<span class="co__name">Socks ×3</span>
<span class="co__price">6,000 KRW</span>
</div>
<div class="co__sum" id="co-sum">
<p class="co__sumHead">
<span class="co__ic" aria-hidden="true"><svg viewBox="0 0 256 256" fill="currentColor"><path d="M224,56V208l-32-16-32,16-32-16L96,208,64,192,32,208V56a8,8,0,0,1,8-8H216A8,8,0,0,1,224,56Z" opacity="0.2"/><path d="M72,104a8,8,0,0,1,8-8h96a8,8,0,0,1,0,16H80A8,8,0,0,1,72,104Zm8,40h96a8,8,0,0,0,0-16H80a8,8,0,0,0,0,16ZM232,56V208a8,8,0,0,1-11.58,7.15L192,200.94l-28.42,14.21a8,8,0,0,1-7.16,0L128,200.94,99.58,215.15a8,8,0,0,1-7.16,0L64,200.94,35.58,215.15A8,8,0,0,1,24,208V56A16,16,0,0,1,40,40H216A16,16,0,0,1,232,56Zm-16,0H40V195.06l20.42-10.22a8,8,0,0,1,7.16,0L96,199.06l28.42-14.22a8,8,0,0,1,7.16,0L160,199.06l28.42-14.22a8,8,0,0,1,7.16,0L216,195.06Z"/></svg></span>
Order summary
</p>
<p class="co__row"><span>Item total</span><span>58,000 KRW</span></p>
<p class="co__row"><span>Shipping</span><span>3,000 KRW</span></p>
<p class="co__row co__row--total"><span>Total</span><span>61,000 KRW</span></p>
</div>
</div>
</div>
<div class="co__bar" id="co-bar">
<span class="co__barTotal">61,000 KRW</span>
<button class="co__pay" type="button">
<span class="co__ic" aria-hidden="true"><svg viewBox="0 0 256 256" fill="currentColor"><path d="M232,96v96a8,8,0,0,1-8,8H32a8,8,0,0,1-8-8V96Z" opacity="0.2"/><path d="M224,48H32A16,16,0,0,0,16,64V192a16,16,0,0,0,16,16H224a16,16,0,0,0,16-16V64A16,16,0,0,0,224,48Zm0,16V88H32V64Zm0,128H32V104H224v88Zm-16-24a8,8,0,0,1-8,8H168a8,8,0,0,1,0-16h32A8,8,0,0,1,208,168Zm-64,0a8,8,0,0,1-8,8H120a8,8,0,0,1,0-16h16A8,8,0,0,1,144,168Z"/></svg></span>
Pay now
</button>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 주문 요약이 시야에 들어오면 하단 결제 바가 translateY 로 내려가 자리를 비운다.
// 바는 스크롤 상자 밖에 position: absolute 로 겹쳐 둔다 — 스크롤이 0 인 첫 장면에서도
// 제자리에 서 있어야 하고, 상자 안에 넣으면 내용과 함께 흘러가 버린다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
.co {
position: relative;
box-sizing: border-box;
width: min(300px, 92vw);
border-radius: $r-card;
background: $subject-cream;
color: $subject-ink;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
overflow: hidden;
}
.co * { box-sizing: border-box; }
.co__view {
height: 168px;
overflow-y: auto;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
&:focus-visible { outline: 2px solid $color; outline-offset: -4px; }
}
.co__pad {
display: grid;
gap: $sp-2;
// 아래 여백은 얇게 — 끝까지 내려가면 바는 이미 물러나 있으므로, 바 높이만큼 비워 두면
// 마지막 장면이 요약 카드 아래의 빈 크림색 띠가 된다
padding: $sp-3 $sp-3 $sp-4;
}
.co__title {
margin: 0;
font-size: 13px;
font-weight: 700;
line-height: 1.3;
}
.co__item {
display: flex;
align-items: center;
gap: $sp-2;
}
.co__thumb {
flex: 0 0 auto;
width: 28px;
height: 28px;
border-radius: $r-sm;
background: rgba(23, 20, 26, .1);
}
.co__name {
flex: 1 1 auto;
font-size: 11px;
line-height: 1.4;
}
.co__price {
font-size: 11px;
font-weight: 700;
line-height: 1.4;
}
// 요약 카드 — 바가 내려간 자리를 이 카드가 채운다 (빈 장면이 대표 이미지로 나가지 않게)
.co__sum {
padding: $sp-3;
border-radius: $r-control;
background: rgba(47, 109, 246, .12);
}
.co__sumHead {
display: flex;
align-items: center;
gap: $sp-1;
margin: 0 0 $sp-2;
font-size: 11px;
font-weight: 700;
line-height: 1.3;
color: $subject-blue-deep; // 파란 tint 카드 위 파란 글자 — 진한 파랑이라야 AA
}
.co__ic {
display: inline-grid;
place-items: center;
width: 14px;
height: 14px;
}
.co__ic svg { width: 100%; height: 100%; }
.co__row {
display: flex;
justify-content: space-between;
gap: $sp-2;
margin: 0;
font-size: 11px;
line-height: 1.6;
}
.co__row--total {
margin-top: $sp-1;
padding-top: $sp-1;
border-top: 1px solid rgba(23, 20, 26, .16);
font-size: 12px;
font-weight: 700;
}
// ── 하단 결제 바 ──
.co__bar {
position: absolute;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-2 $sp-3;
background: $subject-ink;
color: $subject-cream;
transform: translateY(0);
transition: transform $dur-base $easing;
}
.co.is-down .co__bar { transform: translateY(110%); }
.co__barTotal {
font-size: 13px;
font-weight: 700;
line-height: 1.4;
}
.co__pay {
display: inline-flex;
align-items: center;
gap: $sp-1;
margin-left: auto;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: $stage-yellow;
color: $subject-ink;
font-family: inherit;
font-size: 11px;
font-weight: 700;
line-height: 1.4;
cursor: pointer;
&:focus-visible { outline: 2px solid $subject-cream; outline-offset: 2px; }
}
// ── 격자 미리보기 루프 ──
// 주문서가 실제로 스크롤될 때 보이는 것과 같은 순서: 내용이 올라가 요약이 드러나면 바가 내려간다.
// 내용이 멈춘 뒤에 바를 움직여, 가장 크게 변하는 프레임(= 대표 이미지)이 늘 「바가 서 있는 주문서」 가 되게 한다
@keyframes coScroll {
0%, 12% { transform: translateY(0); }
40%, 70% { transform: translateY(-120px); }
90%, 100% { transform: translateY(0); }
}
@keyframes coBar {
0%, 44% { transform: translateY(0); }
56%, 68% { transform: translateY(110%); }
84%, 100% { transform: translateY(0); }
}
.co.is-demo {
.co__pad {
animation-name: coScroll;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.co__bar {
animation-name: coBar;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
}
@media (max-width: 340px) {
.co__view { height: 140px; }
.co__pad { padding: $sp-2 $sp-2 $sp-3; }
.co__title { font-size: 12px; }
.co__thumb { width: 24px; height: 24px; }
.co__sum { padding: $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.co__bar { transition: none !important; }
.co.is-demo .co__pad,
.co.is-demo .co__bar { animation: none !important; transform: none !important; }
}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);
}
.co {
position: relative;
box-sizing: border-box;
width: min(300px, 92vw);
border-radius: 16px;
background: #fff7e6;
color: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
overflow: hidden;
}
.co * {
box-sizing: border-box;
}
.co__view {
height: 168px;
overflow-y: auto;
scrollbar-width: none;
}
.co__view::-webkit-scrollbar {
display: none;
}
.co__view:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: -4px;
}
.co__pad {
display: grid;
gap: 8px;
padding: 12px 12px 16px;
}
.co__title {
margin: 0;
font-size: 13px;
font-weight: 700;
line-height: 1.3;
}
.co__item {
display: flex;
align-items: center;
gap: 8px;
}
.co__thumb {
flex: 0 0 auto;
width: 28px;
height: 28px;
border-radius: 8px;
background: rgba(23, 20, 26, 0.1);
}
.co__name {
flex: 1 1 auto;
font-size: 11px;
line-height: 1.4;
}
.co__price {
font-size: 11px;
font-weight: 700;
line-height: 1.4;
}
.co__sum {
padding: 12px;
border-radius: 12px;
background: rgba(47, 109, 246, 0.12);
}
.co__sumHead {
display: flex;
align-items: center;
gap: 4px;
margin: 0 0 8px;
font-size: 11px;
font-weight: 700;
line-height: 1.3;
color: #0a4fe8;
}
.co__ic {
display: inline-grid;
place-items: center;
width: 14px;
height: 14px;
}
.co__ic svg {
width: 100%;
height: 100%;
}
.co__row {
display: flex;
justify-content: space-between;
gap: 8px;
margin: 0;
font-size: 11px;
line-height: 1.6;
}
.co__row--total {
margin-top: 4px;
padding-top: 4px;
border-top: 1px solid rgba(23, 20, 26, 0.16);
font-size: 12px;
font-weight: 700;
}
.co__bar {
position: absolute;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: #17141a;
color: #fff7e6;
transform: translateY(0);
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.co.is-down .co__bar {
transform: translateY(110%);
}
.co__barTotal {
font-size: 13px;
font-weight: 700;
line-height: 1.4;
}
.co__pay {
display: inline-flex;
align-items: center;
gap: 4px;
margin-left: auto;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: #ffd23f;
color: #17141a;
font-family: inherit;
font-size: 11px;
font-weight: 700;
line-height: 1.4;
cursor: pointer;
}
.co__pay:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
@keyframes coScroll {
0%, 12% {
transform: translateY(0);
}
40%, 70% {
transform: translateY(-120px);
}
90%, 100% {
transform: translateY(0);
}
}
@keyframes coBar {
0%, 44% {
transform: translateY(0);
}
56%, 68% {
transform: translateY(110%);
}
84%, 100% {
transform: translateY(0);
}
}
.co.is-demo .co__pad {
animation-name: coScroll;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.co.is-demo .co__bar {
animation-name: coBar;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@media (max-width: 340px) {
.co__view {
height: 140px;
}
.co__pad {
padding: 8px 8px 12px;
}
.co__title {
font-size: 12px;
}
.co__thumb {
width: 24px;
height: 24px;
}
.co__sum {
padding: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.co__bar {
transition: none !important;
}
.co.is-demo .co__pad,
.co.is-demo .co__bar {
animation: none !important;
transform: none !important;
}
}// 주문 요약이 시야에 들어오면 하단 결제 바가 내려가 자리를 비운다.
// 「보이는가」 는 스크롤 좌표 계산이 아니라 IntersectionObserver 가 판정한다 — root 는 Order form 스크롤 상자.
var co = document.getElementById('co');
var view = document.getElementById('co-view');
var summary = document.getElementById('co-sum');
// 관찰자는 붙자마자 한 번 알려 주므로, 미리보기 루프는 사람이 실제로 스크롤할 때만 끈다
view.addEventListener('scroll', function () {
co.classList.remove('is-demo');
}, { passive: true });
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);
}import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./CheckoutStickyTotalBar.module.scss";
export type CheckoutLine = { name: string; price: string };
export type CheckoutStickyTotalBarProps = {
title?: string;
lines?: CheckoutLine[];
summaryLabel?: string;
itemsLabel?: string;
itemsTotal?: string;
shippingLabel?: string;
shippingFee?: string;
totalLabel?: string;
total?: string;
payLabel?: string;
groupLabel?: string;
bodyLabel?: string;
/** 격자 미리보기용 2초 자동 루프. 사람이 스크롤하면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// ambient 가 얇아 DOM 요소 타입을 못 쓴다 — 관찰자에 넘길 만큼만 로컬 타입으로 선언
type Box = { scrollTop: number };
function ReceiptIcon() {
return (
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor" aria-hidden="true"><path d="M224,56V208l-32-16-32,16-32-16L96,208,64,192,32,208V56a8,8,0,0,1,8-8H216A8,8,0,0,1,224,56Z" opacity="0.2"/><path d="M72,104a8,8,0,0,1,8-8h96a8,8,0,0,1,0,16H80A8,8,0,0,1,72,104Zm8,40h96a8,8,0,0,0,0-16H80a8,8,0,0,0,0,16ZM232,56V208a8,8,0,0,1-11.58,7.15L192,200.94l-28.42,14.21a8,8,0,0,1-7.16,0L128,200.94,99.58,215.15a8,8,0,0,1-7.16,0L64,200.94,35.58,215.15A8,8,0,0,1,24,208V56A16,16,0,0,1,40,40H216A16,16,0,0,1,232,56Zm-16,0H40V195.06l20.42-10.22a8,8,0,0,1,7.16,0L96,199.06l28.42-14.22a8,8,0,0,1,7.16,0L160,199.06l28.42-14.22a8,8,0,0,1,7.16,0L216,195.06Z"/></svg>
);
}
function CreditCardIcon() {
return (
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor" aria-hidden="true"><path d="M232,96v96a8,8,0,0,1-8,8H32a8,8,0,0,1-8-8V96Z" opacity="0.2"/><path d="M224,48H32A16,16,0,0,0,16,64V192a16,16,0,0,0,16,16H224a16,16,0,0,0,16-16V64A16,16,0,0,0,224,48Zm0,16V88H32V64Zm0,128H32V104H224v88Zm-16-24a8,8,0,0,1-8,8H168a8,8,0,0,1,0-16h32A8,8,0,0,1,208,168Zm-64,0a8,8,0,0,1-8,8H120a8,8,0,0,1,0-16h16A8,8,0,0,1,144,168Z"/></svg>
);
}
export default function CheckoutStickyTotalBar({
title = "주문서",
lines = [
{ name: "머그컵 2개", price: "24,000원" },
{ name: "티셔츠 1벌", price: "19,000원" },
{ name: "에코백 1개", price: "9,000원" },
{ name: "양말 3켤레", price: "6,000원" },
],
summaryLabel = "주문 요약",
itemsLabel = "상품 금액",
itemsTotal = "58,000원",
shippingLabel = "배송비",
shippingFee = "3,000원",
totalLabel = "총 결제 금액",
total = "61,000원",
payLabel = "결제하기",
groupLabel = "주문서와 하단 결제 바",
bodyLabel = "주문서",
demo = true,
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: CheckoutStickyTotalBarProps) {
const [down, setDown] = useState(false);
const [live, setLive] = useState(demo);
const viewRef = useRef<HTMLDivElement>(null);
const summaryRef = useRef<HTMLDivElement>(null);
// 「보이는가」 는 스크롤 좌표 계산이 아니라 관찰자가 판정한다 — root 는 주문서 스크롤 상자
useEffect(() => {
const view = viewRef.current;
const summary = summaryRef.current;
if (!view || !summary) return;
const watcher = new IntersectionObserver(
(entries) => {
for (const entry of entries) setDown(entry.isIntersecting);
},
{ root: view, threshold: 0.45 },
);
watcher.observe(summary);
return () => watcher.disconnect();
}, []);
// 관찰자는 붙자마자 한 번 알려 주므로, 미리보기 루프는 사람이 실제로 스크롤할 때만 끈다
function onScroll() {
const view: Box | null = viewRef.current;
if (!view) return;
setLive(false);
}
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const cls = [styles.co, live ? styles.isDemo : "", down ? styles.isDown : ""].filter(Boolean).join(" ");
return (
<section className={cls} style={vars} aria-label={groupLabel}>
<div className={styles.view} ref={viewRef} onScroll={onScroll} tabIndex={0} role="region" aria-label={bodyLabel}>
<div className={styles.pad}>
<p className={styles.title}>{title}</p>
{lines.map((line) => (
<div className={styles.item} key={line.name}>
<span className={styles.thumb} aria-hidden="true" />
<span className={styles.name}>{line.name}</span>
<span className={styles.price}>{line.price}</span>
</div>
))}
<div className={styles.sum} ref={summaryRef}>
<p className={styles.sumHead}>
<span className={styles.ic} aria-hidden="true"><ReceiptIcon /></span>
{summaryLabel}
</p>
<p className={styles.row}><span>{itemsLabel}</span><span>{itemsTotal}</span></p>
<p className={styles.row}><span>{shippingLabel}</span><span>{shippingFee}</span></p>
<p className={`${styles.row} ${styles.rowTotal}`}><span>{totalLabel}</span><span>{total}</span></p>
</div>
</div>
</div>
<div className={styles.bar}>
<span className={styles.barTotal}>{total}</span>
<button className={styles.pay} type="button">
<span className={styles.ic} aria-hidden="true"><CreditCardIcon /></span>
{payLabel}
</button>
</div>
</section>
);
}09 · Bottom pay bar that steps aside once the summary is on screen — Measured area 25.205% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
Use it for sticky pay buttons on mobile checkout or total bars on long order forms.
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);
}
Where it breaks — the trap
The first thing to break was 0fr itself. Four items (02, 03, 05, 06) fold cells with grid-template-rows: 0fr ↔ 1fr, and the first attempt put vertical padding on the folding cell itself, which left an 8px sliver behind instead of closing it. 0fr zeroes the cell's content height but leaves its padding alone. The fix splits the structure in two: the outer cell carries only grid-template-rows and min-height: 0, while the padding lives on the inner row. The outer cell reaches height 0 and clips the padded row inside, so the cell closes completely and quietly.
The second problem was posters that showed the disappearing side. A poster picks the frame with the largest change from the one before it, and an exit usually changes more pixels than an entrance, so 04's warning strip leaving kept winning. Spreading the retreat over four slow frames while keeping the arrival a one-frame cut let the standing strip win again, and every loop returns within 89% of its two seconds. Items 02 and 03 unwind their return slowly across 70–86% for the same reason, and 09 was redesigned outright so the summary card fills the seat the bar vacates — otherwise the poster is an empty screen with no bar.
The third was overlapping numbers turning into translucent ghosts. Item 01 previews a 2→3 quantity change with two numbers stacked in one cell, and the first crossfade left both numbers half-readable on the poster. Every number and label swap now goes through a steps(1, end) cut, and because a cut alone leaves too few moving frames for the measurement, each one carries a small translateY lift alongside. Item 05 opens with a cut and lets the unfolded text rise once to fill frames.
The fourth only appeared after the preview loop was removed. Item 03 overlays the code text SAVE3000 on the input for the preview, and hiding it with display: none reshapes the input in the static path. The overlay keeps its seat with color: transparent, and item 04's "typed value" does the same. Item 06 dodged a different trap in advance: raising the summary line vertically would have the overflow: hidden cell clip it half-visible on the poster, so it slides in horizontally instead.
The last traps lived outside the stage rather than on it. Item 04's warning strip is absolutely positioned over the product row, and the first version covered the row so the poster lost the product name; reserving a 24px slot above the row and moving the background onto the row fixed it. Item 09's bar is also absolutely positioned over the scroll box — it has to stand at scroll zero, which rules out sticky, because a sticky bar never engages in the preview's zero-scroll state and appears in none of the 24 frames. Letting the shopper choose the arrival date by hand is the point where the month-view calendar grid joins the page. The sources of all nine ship inside the zip with the exact values quoted here, and the password that opens it is be3rd37n.
Accessibility (reduced-motion)
What each of the nine turns off under prefers-reduced-motion: reduce differs item by item, but the underlying principle is shared: self-running motion stops, and the result of the shopper's action — numbers, states, sentences — stays. Item 01 stops the digit swap and tint but shows the current value; 02 stops the fold and shows both list counts; 03 stops with the discount row open; 04 stops the bounce and keeps the strip, the red outline, and the clamped maximum; 05 shows one card picked and expanded; 06 shows the folded shipping block and the one-line summary; 07 stops the roll and shows the current date; 08 stops the drawing and shows four items on; 09 stops with the bar down. Every declaration in these reduce blocks carries !important, because the preview-loop rules stack classes and would otherwise win on specificity. The reference is MDN's prefers-reduced-motion page, and the observer that item 09 uses is documented in MDN's Intersection Observer API guide.
What screen-reader users get varies by item.
| Item | What is actually attached | What it announces |
|---|---|---|
| 01 Quantity sync | aria-label on quantity buttons |
Which product's quantity rises or falls |
| 02 Save for later | aria-label on move buttons |
Which item moves to which list |
| 03 Coupon row | aria-live="polite" |
Applied, or unknown code |
| 04 Stock limit | role="alert" |
Remaining stock and the clamped maximum |
| 05 Payment method | aria-expanded + radio group |
Which method is expanded |
| 06 Same as orderer | aria-hidden + inert |
Folded fields leave the focus order |
| 07 Arrival chip | aria-live="polite" |
The new arrival date and fee |
| 08 Consent bundle | indeterminate |
How many items are on |
| 09 Pay bar | role="region" + focusable order list |
The order form region and the bar |
If the forms themselves aren't built yet, 9 form validation UI patterns is the input-side neighbor, and a multi-step order form is covered in 9 multi-step form wizard patterns. The broader input roundup sits in 9 input design ideas, and tabular order lists sit next to 9 data table interactions. How this site renders and measures demos before each article is written is described on the about page.
FAQ
Why does this cart page design fold the cart line instead of removing it?
Removing the row from the DOM makes everything below it jump at once, and the shopper loses track of what disappeared. Folding with grid-template-rows: 0fr shrinks the row's space smoothly so the eye follows the motion, and the same product appearing in the twin list explains the move as movement itself. Small informative widgets that earn a slot beside the summary — weather widget design is one — sit comfortably at this scale.
The discount row never appears when I apply the coupon. Why?
The cell the row lives in starts at grid-template-rows: 0fr, but without min-height: 0 on the inner element the cell opens to the content's height and stays there. Pair min-height: 0 with overflow: hidden on the inner element — as .pm__detail does here — and the cell travels between 0fr and 1fr both ways.
Do I need all nine on one checkout screen?
No, the nine are ingredients. The minimum set is 01 (quantity and total), 08 (consent), and 09 (pay bar); add 03 if you take coupons and 06 if shipping differs from the orderer. One screen carrying all nine gets busy, so spread them one or two per step of the checkout flow.









