07配送方法を変えると日付が滑って変わる到着チップ
通常・お急ぎ・早朝のどれかを選ぶと到着予定チップの中の日付が上へ押し出され、新しい日付が下から上がって収まります。
使いどころ配送方法ごとの到着予定日の案内、受け取り時間の選択
overflowtranslateYaria-live

<main class="stage">
<section class="sp is-demo" id="sp" aria-label="配送方法を選ぶ">
<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">配送方法</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">通常</span></label>
<label class="sp__opt"><input class="sp__radio" type="radio" name="speed" value="1"><span class="sp__pill">お急ぎ</span></label>
<label class="sp__opt"><input class="sp__radio" type="radio" name="speed" value="2"><span class="sp__pill">早朝</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">到着予定</span>
<span class="sp__win" aria-hidden="true"><span class="sp__roll">
<span class="sp__cell">9月17日 (木)</span>
<span class="sp__cell">9月14日 (月)</span>
<span class="sp__cell">9月13日 (日)</span>
</span></span>
</p>
<p class="sp__line sp__line--fee">
<span class="sp__label">送料</span>
<span class="sp__win sp__win--fee" aria-hidden="true"><span class="sp__roll">
<span class="sp__cell sp__cell--fee">3,000ウォン</span>
<span class="sp__cell sp__cell--fee">5,000ウォン</span>
<span class="sp__cell sp__cell--fee">7,000ウォン</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 하나가 바뀌고, 도착 칩과 送料 칸이 같은 값으로 밀려 올라간다.
// 칸 안의 글자는 aria-hidden 이라 읽히지 않는다 — 바뀐 결과는 aria-live 칸이 한 문장으로 읽어 준다.
var sp = document.getElementById('sp');
var live = document.getElementById('sp-live');
var summary = [
'9月17日 (木) 到着 · 送料 3,000ウォン',
'9月14日 (月) 到着 · 送料 5,000ウォン',
'9月13日 (日) 到着 · 送料 7,000ウォン'
];
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 配送方法を変えると日付が滑って変わる到着チップ — 計測 累積面積 9.291% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · 決済画面 デザイン 9選 — カートから支払いまでコピペOK
このデモが属する記事
決済画面 デザイン 9選 — カートから支払いまでコピペOK決済画面 デザインとは、買い物かごに入れた人を「これが欲しい」から支払いボタンの手前まで案内する一連の画面のことです。数量を変えると合計が追いかける行、クーポンで割り込む割引行、選んだ支払い方法の下に開く詳細枠。九つともHTML・SCSS・ この記事のデモをすべて見る


