07Arrival chip whose date slides over when you change the shipping speed
Choosing standard, express, or dawn delivery pushes the date inside the arrival chip up and out while the new date rises into its place.
Best forArrival estimates per shipping speed, pickup time selection
overflowtranslateYaria-live

<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 · Checkout Page Design — 9 Cart Patterns, No Library
The post this demo lives in
Checkout Page Design — 9 Cart Patterns, No LibraryCheckout page design covers every screen between "I want this" and the pay button. These nine patterns are plain HTML, S See all demos in this post
If you like this effect - similar demos
Inputs04 Resume strip that remembers where you stopped9 Multi Step Form UI Patterns — Branch and Resume
Inputs03 Inline hint that flips while you type9 Form Validation UI Patterns — As You Type
Inputs04 Wheel delta — a stepper whose value changes by the amount and direction the wheel moves over itNumber Stepper UI: 9 Interaction Patterns