02通貨を変えると記号と金額が同時に変わる
一覧から通貨を選ぶとトリガーのコードと記号、換算金額が一度に変わり、開いた一覧がはみ出さないよう下の余白をあらかじめ確保しています。
使いどころ複数の国で売るサービスの価格ヘッダー、地域別の決済案内
aria-expandedaria-selectedpadding-bottom

<main class="stage">
<div class="cs is-demo" data-cur="usd">
<div class="cs__left">
<p class="cs__plan">スタンダードプラン</p>
<div class="cs__priceline" aria-live="polite">
<span class="cs__face" data-cur="usd"><span class="cs__sym">$</span><span class="cs__amt">29</span><span class="cs__unit">/ 月</span></span>
<span class="cs__face" data-cur="krw"><span class="cs__sym">₩</span><span class="cs__amt">39,000</span><span class="cs__unit">/ 月</span></span>
<span class="cs__face" data-cur="jpy"><span class="cs__sym">¥</span><span class="cs__amt">4,400</span><span class="cs__unit">/ 月</span></span>
<span class="cs__face" data-cur="eur"><span class="cs__sym">€</span><span class="cs__amt">27</span><span class="cs__unit">/ 月</span></span>
</div>
<p class="cs__note">税別</p>
</div>
<div class="cs__pick">
<button class="cs__trig" type="button" id="cs-trig" aria-expanded="false" aria-haspopup="listbox" aria-label="通貨を選ぶ">
<span class="cs__code" id="cs-code">USD</span>
<span class="cs__tsym" id="cs-tsym">$</span>
<span class="cs__caret" aria-hidden="true"><svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="m208 96l-80 80l-80-80Z" opacity=".2"/><path d="M215.39 92.94A8 8 0 0 0 208 88H48a8 8 0 0 0-5.66 13.66l80 80a8 8 0 0 0 11.32 0l80-80a8 8 0 0 0 1.73-8.72M128 164.69L67.31 104h121.38Z"/></g></svg></span>
</button>
<div class="cs__pop">
<span class="cs__hl" aria-hidden="true"></span>
<ul class="cs__list" role="listbox" aria-label="通貨を選ぶ">
<li class="cs__opt" role="option" aria-selected="true" tabindex="0" data-cur="usd" data-code="USD" data-sym="$"><span class="cs__dot" aria-hidden="true"></span><span class="cs__oc">USD</span><span class="cs__os">$</span></li>
<li class="cs__opt" role="option" aria-selected="false" tabindex="0" data-cur="krw" data-code="KRW" data-sym="₩"><span class="cs__dot" aria-hidden="true"></span><span class="cs__oc">KRW</span><span class="cs__os">₩</span></li>
<li class="cs__opt" role="option" aria-selected="false" tabindex="0" data-cur="jpy" data-code="JPY" data-sym="¥"><span class="cs__dot" aria-hidden="true"></span><span class="cs__oc">JPY</span><span class="cs__os">¥</span></li>
<li class="cs__opt" role="option" aria-selected="false" tabindex="0" data-cur="eur" data-code="EUR" data-sym="€"><span class="cs__dot" aria-hidden="true"></span><span class="cs__oc">EUR</span><span class="cs__os">€</span></li>
</ul>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-mint;
@include stage($stage-ink);
.cs {
--row: 22px; // 목록 한 줄 높이 — 표시 띠가 한 칸씩 미끄러지는 거리이기도 하다
box-sizing: border-box;
width: min(400px, 88vw);
display: flex;
align-items: flex-start;
gap: $sp-3;
padding: $sp-4;
// 절대배치 목록이 아래로 내려오는 만큼 자리를 미리 비워 둔다 —
// 절대배치는 부모 높이에서 빠지는데 scrollHeight 는 실제 크기로 잡혀 320px 렌더가 넘친다
padding-bottom: $sp-12 + $sp-2;
border: 2px solid rgba(255, 255, 255, .12);
border-radius: $r-card;
background: rgba(255, 255, 255, .06);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-cream;
}
.cs__left {
flex: 1 1 auto;
min-width: 0;
}
.cs__plan {
margin: 0;
font-size: 12px;
line-height: 18px;
font-weight: 700;
letter-spacing: .04em;
opacity: .72;
}
// 금액 얼굴 넷을 같은 자리에 겹쳐 둔다 — 자리를 옮기지 않아야 컷이 깔끔하다
.cs__priceline {
position: relative;
height: 40px;
margin-top: $sp-2;
transform-origin: left center; // 규칙8: 왼쪽 정렬 금액 — 정렬 변을 고정해야 팝이 자리를 안 흔든다
}
.cs__face {
position: absolute;
left: 0;
top: 0;
display: flex;
align-items: baseline;
gap: $sp-1;
white-space: nowrap;
opacity: 0;
}
.cs__sym,
.cs__amt {
color: $color;
font-size: 30px;
line-height: 40px;
font-weight: 800;
}
.cs__unit {
font-size: 12px;
line-height: 18px;
font-weight: 700;
opacity: .7;
}
.cs__note {
margin: $sp-1 0 0;
font-size: 11px;
line-height: 16px;
font-weight: 600;
opacity: .55;
}
.cs__pick {
position: relative;
flex: 0 0 auto;
width: 96px;
}
.cs__trig {
box-sizing: border-box;
display: flex;
align-items: center;
gap: $sp-1;
width: 100%;
margin: 0;
padding: $sp-1 $sp-2;
border: 2px solid rgba(255, 255, 255, .22);
border-radius: $r-control;
background: rgba(255, 255, 255, .04);
color: $subject-cream;
font-family: inherit;
font-size: 12px;
line-height: 18px;
font-weight: 700;
cursor: pointer;
}
.cs__trig:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.cs__tsym { color: $color; }
.cs__caret {
margin-left: auto;
display: inline-flex;
opacity: .6;
}
.cs__caret svg { display: block; }
// 목록은 띄워 두되(레이아웃을 밀지 않는다) 뿌리의 padding-bottom 이 그 높이를 미리 잡아 둔다
.cs__pop {
position: absolute;
left: 0;
right: 0;
top: calc(100% + 4px);
box-sizing: border-box;
padding: $sp-1 0;
border: 2px solid rgba(255, 255, 255, .16);
border-radius: $r-control;
background: $subject-ink;
box-shadow: $shadow-raised;
transform-origin: top center; // 규칙8: 트리거 아래 변에서 펼침
transform: scaleY(.5) translateY(-8px);
opacity: 0;
visibility: hidden;
}
.cs__hl {
position: absolute;
left: 4px;
right: 4px;
top: 4px;
height: var(--row);
border-radius: $r-sm;
background: rgba(255, 255, 255, .1);
}
.cs__list {
position: relative;
margin: 0;
padding: 0;
list-style: none;
}
.cs__opt {
display: flex;
align-items: center;
gap: $sp-2;
height: var(--row);
padding: 0 $sp-2;
font-size: 11px;
line-height: 16px;
font-weight: 700;
cursor: pointer;
}
.cs__opt:focus-visible { outline: 2px solid $color; outline-offset: -2px; }
.cs__dot {
flex: 0 0 auto;
width: 6px;
height: 6px;
border-radius: 50%;
background: $color;
opacity: 0;
}
.cs__os { margin-left: auto; color: $color; }
// ── 사람이 만진 뒤(정적 경로) — 상태는 aria-expanded · aria-selected · data-cur 에만 있다 ──
.cs__trig[aria-expanded="true"] ~ .cs__pop {
transform: none;
opacity: 1;
visibility: visible;
}
.cs__opt[aria-selected="true"] .cs__dot { opacity: 1; }
.cs:not(.is-demo)[data-cur="usd"] .cs__face[data-cur="usd"],
.cs:not(.is-demo)[data-cur="krw"] .cs__face[data-cur="krw"],
.cs:not(.is-demo)[data-cur="jpy"] .cs__face[data-cur="jpy"],
.cs:not(.is-demo)[data-cur="eur"] .cs__face[data-cur="eur"] { opacity: 1; }
.cs:not(.is-demo)[data-cur="krw"] .cs__hl { transform: translateY(var(--row)); }
.cs:not(.is-demo)[data-cur="jpy"] .cs__hl { transform: translateY(calc(var(--row) * 2)); }
.cs:not(.is-demo)[data-cur="eur"] .cs__hl { transform: translateY(calc(var(--row) * 3)); }
.cs:not(.is-demo) .cs__pop {
transition-property: transform, opacity, visibility;
transition-duration: $duration;
transition-timing-function: $easing;
}
.cs:not(.is-demo) .cs__hl {
transition-property: transform;
transition-duration: $duration;
transition-timing-function: $easing;
}
// ── 자동 루프(2초) — 목록은 열린 채로 머물고 선택만 20% 마다 컷으로 넘어간다. 닫힘은 재현하지 않는다 ──
.cs.is-demo .cs__pop {
visibility: visible;
animation-name: cs-open;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes cs-open {
0% { transform: scaleY(.5) translateY(-8px); opacity: 0; }
10% { transform: none; opacity: 1; }
100% { transform: none; opacity: 1; }
}
// 표시 띠만 부드럽게 미끄러진다 — 컷 옆에 움직인 프레임을 채우는 몫
.cs.is-demo .cs__hl {
animation-name: cs-hl;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes cs-hl {
0%, 10% { transform: translateY(0); }
20%, 30% { transform: translateY(var(--row)); }
40%, 50% { transform: translateY(calc(var(--row) * 2)); }
60%, 70% { transform: translateY(calc(var(--row) * 3)); }
80%, 100% { transform: translateY(0); }
}
// 금액 얼굴과 표시 점은 같은 시계로 한 프레임에 끊는다 — 크로스페이드는 반투명 유령을 남긴다
.cs.is-demo .cs__face,
.cs.is-demo .cs__dot {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.cs.is-demo .cs__face[data-cur="usd"],
.cs.is-demo .cs__opt[data-cur="usd"] .cs__dot { animation-name: cs-usd; }
.cs.is-demo .cs__face[data-cur="krw"],
.cs.is-demo .cs__opt[data-cur="krw"] .cs__dot { animation-name: cs-krw; }
.cs.is-demo .cs__face[data-cur="jpy"],
.cs.is-demo .cs__opt[data-cur="jpy"] .cs__dot { animation-name: cs-jpy; }
.cs.is-demo .cs__face[data-cur="eur"],
.cs.is-demo .cs__opt[data-cur="eur"] .cs__dot { animation-name: cs-eur; }
@keyframes cs-usd {
0% { opacity: 1; }
20% { opacity: 0; }
80% { opacity: 1; }
100% { opacity: 1; }
}
@keyframes cs-krw {
0% { opacity: 0; }
20% { opacity: 1; }
40% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes cs-jpy {
0% { opacity: 0; }
40% { opacity: 1; }
60% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes cs-eur {
0% { opacity: 0; }
60% { opacity: 1; }
80% { opacity: 0; }
100% { opacity: 0; }
}
// 금액이 갈릴 때마다 가격 줄이 한 번 눌렸다 편다
.cs.is-demo .cs__priceline {
animation-name: cs-pop;
animation-duration: $dur-loop;
animation-timing-function: $ease-pop;
animation-iteration-count: infinite;
}
@keyframes cs-pop {
0%, 16% { transform: scale(1); }
22% { transform: scale(1.06); }
28%, 36% { transform: scale(1); }
42% { transform: scale(1.06); }
48%, 56% { transform: scale(1); }
62% { transform: scale(1.06); }
68%, 76% { transform: scale(1); }
82% { transform: scale(1.06); }
88%, 100% { transform: scale(1); }
}
@media (max-width: 340px) {
.cs {
--row: 20px;
gap: $sp-2;
padding: $sp-3;
padding-bottom: $sp-12 + $sp-2;
}
.cs__plan { font-size: 11px; line-height: 16px; }
.cs__priceline { height: 30px; margin-top: $sp-1; }
.cs__sym,
.cs__amt { font-size: 21px; line-height: 30px; }
.cs__pick { width: 88px; }
.cs__trig { padding: $sp-1; font-size: 11px; line-height: 16px; }
}
@media (prefers-reduced-motion: reduce) {
.cs.is-demo .cs__pop,
.cs.is-demo .cs__hl,
.cs.is-demo .cs__face,
.cs.is-demo .cs__dot,
.cs.is-demo .cs__priceline { animation: none !important; }
.cs:not(.is-demo) .cs__pop,
.cs:not(.is-demo) .cs__hl { transition: none !important; }
.cs.is-demo .cs__pop { transform: none; opacity: 1; }
.cs.is-demo .cs__face[data-cur="usd"] { opacity: 1; }
.cs.is-demo .cs__opt[data-cur="usd"] .cs__dot { opacity: 1; }
}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);
}
.cs {
--row: 22px;
box-sizing: border-box;
width: min(400px, 88vw);
display: flex;
align-items: flex-start;
gap: 12px;
padding: 16px;
padding-bottom: 56px;
border: 2px solid rgba(255, 255, 255, 0.12);
border-radius: 16px;
background: rgba(255, 255, 255, 0.06);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #fff7e6;
}
.cs__left {
flex: 1 1 auto;
min-width: 0;
}
.cs__plan {
margin: 0;
font-size: 12px;
line-height: 18px;
font-weight: 700;
letter-spacing: 0.04em;
opacity: 0.72;
}
.cs__priceline {
position: relative;
height: 40px;
margin-top: 8px;
transform-origin: left center;
}
.cs__face {
position: absolute;
left: 0;
top: 0;
display: flex;
align-items: baseline;
gap: 4px;
white-space: nowrap;
opacity: 0;
}
.cs__sym,
.cs__amt {
color: #4cd4a6;
font-size: 30px;
line-height: 40px;
font-weight: 800;
}
.cs__unit {
font-size: 12px;
line-height: 18px;
font-weight: 700;
opacity: 0.7;
}
.cs__note {
margin: 4px 0 0;
font-size: 11px;
line-height: 16px;
font-weight: 600;
opacity: 0.55;
}
.cs__pick {
position: relative;
flex: 0 0 auto;
width: 96px;
}
.cs__trig {
box-sizing: border-box;
display: flex;
align-items: center;
gap: 4px;
width: 100%;
margin: 0;
padding: 4px 8px;
border: 2px solid rgba(255, 255, 255, 0.22);
border-radius: 12px;
background: rgba(255, 255, 255, 0.04);
color: #fff7e6;
font-family: inherit;
font-size: 12px;
line-height: 18px;
font-weight: 700;
cursor: pointer;
}
.cs__trig:focus-visible {
outline: 2px solid #4cd4a6;
outline-offset: 2px;
}
.cs__tsym {
color: #4cd4a6;
}
.cs__caret {
margin-left: auto;
display: inline-flex;
opacity: 0.6;
}
.cs__caret svg {
display: block;
}
.cs__pop {
position: absolute;
left: 0;
right: 0;
top: calc(100% + 4px);
box-sizing: border-box;
padding: 4px 0;
border: 2px solid rgba(255, 255, 255, 0.16);
border-radius: 12px;
background: #17141a;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transform-origin: top center;
transform: scaleY(0.5) translateY(-8px);
opacity: 0;
visibility: hidden;
}
.cs__hl {
position: absolute;
left: 4px;
right: 4px;
top: 4px;
height: var(--row);
border-radius: 8px;
background: rgba(255, 255, 255, 0.1);
}
.cs__list {
position: relative;
margin: 0;
padding: 0;
list-style: none;
}
.cs__opt {
display: flex;
align-items: center;
gap: 8px;
height: var(--row);
padding: 0 8px;
font-size: 11px;
line-height: 16px;
font-weight: 700;
cursor: pointer;
}
.cs__opt:focus-visible {
outline: 2px solid #4cd4a6;
outline-offset: -2px;
}
.cs__dot {
flex: 0 0 auto;
width: 6px;
height: 6px;
border-radius: 50%;
background: #4cd4a6;
opacity: 0;
}
.cs__os {
margin-left: auto;
color: #4cd4a6;
}
.cs__trig[aria-expanded=true] ~ .cs__pop {
transform: none;
opacity: 1;
visibility: visible;
}
.cs__opt[aria-selected=true] .cs__dot {
opacity: 1;
}
.cs:not(.is-demo)[data-cur=usd] .cs__face[data-cur=usd],
.cs:not(.is-demo)[data-cur=krw] .cs__face[data-cur=krw],
.cs:not(.is-demo)[data-cur=jpy] .cs__face[data-cur=jpy],
.cs:not(.is-demo)[data-cur=eur] .cs__face[data-cur=eur] {
opacity: 1;
}
.cs:not(.is-demo)[data-cur=krw] .cs__hl {
transform: translateY(var(--row));
}
.cs:not(.is-demo)[data-cur=jpy] .cs__hl {
transform: translateY(calc(var(--row) * 2));
}
.cs:not(.is-demo)[data-cur=eur] .cs__hl {
transform: translateY(calc(var(--row) * 3));
}
.cs:not(.is-demo) .cs__pop {
transition-property: transform, opacity, visibility;
transition-duration: 220ms;
transition-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
}
.cs:not(.is-demo) .cs__hl {
transition-property: transform;
transition-duration: 220ms;
transition-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
}
.cs.is-demo .cs__pop {
visibility: visible;
animation-name: cs-open;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes cs-open {
0% {
transform: scaleY(0.5) translateY(-8px);
opacity: 0;
}
10% {
transform: none;
opacity: 1;
}
100% {
transform: none;
opacity: 1;
}
}
.cs.is-demo .cs__hl {
animation-name: cs-hl;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes cs-hl {
0%, 10% {
transform: translateY(0);
}
20%, 30% {
transform: translateY(var(--row));
}
40%, 50% {
transform: translateY(calc(var(--row) * 2));
}
60%, 70% {
transform: translateY(calc(var(--row) * 3));
}
80%, 100% {
transform: translateY(0);
}
}
.cs.is-demo .cs__face,
.cs.is-demo .cs__dot {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.cs.is-demo .cs__face[data-cur=usd],
.cs.is-demo .cs__opt[data-cur=usd] .cs__dot {
animation-name: cs-usd;
}
.cs.is-demo .cs__face[data-cur=krw],
.cs.is-demo .cs__opt[data-cur=krw] .cs__dot {
animation-name: cs-krw;
}
.cs.is-demo .cs__face[data-cur=jpy],
.cs.is-demo .cs__opt[data-cur=jpy] .cs__dot {
animation-name: cs-jpy;
}
.cs.is-demo .cs__face[data-cur=eur],
.cs.is-demo .cs__opt[data-cur=eur] .cs__dot {
animation-name: cs-eur;
}
@keyframes cs-usd {
0% {
opacity: 1;
}
20% {
opacity: 0;
}
80% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@keyframes cs-krw {
0% {
opacity: 0;
}
20% {
opacity: 1;
}
40% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes cs-jpy {
0% {
opacity: 0;
}
40% {
opacity: 1;
}
60% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes cs-eur {
0% {
opacity: 0;
}
60% {
opacity: 1;
}
80% {
opacity: 0;
}
100% {
opacity: 0;
}
}
.cs.is-demo .cs__priceline {
animation-name: cs-pop;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
@keyframes cs-pop {
0%, 16% {
transform: scale(1);
}
22% {
transform: scale(1.06);
}
28%, 36% {
transform: scale(1);
}
42% {
transform: scale(1.06);
}
48%, 56% {
transform: scale(1);
}
62% {
transform: scale(1.06);
}
68%, 76% {
transform: scale(1);
}
82% {
transform: scale(1.06);
}
88%, 100% {
transform: scale(1);
}
}
@media (max-width: 340px) {
.cs {
--row: 20px;
gap: 8px;
padding: 12px;
padding-bottom: 56px;
}
.cs__plan {
font-size: 11px;
line-height: 16px;
}
.cs__priceline {
height: 30px;
margin-top: 4px;
}
.cs__sym,
.cs__amt {
font-size: 21px;
line-height: 30px;
}
.cs__pick {
width: 88px;
}
.cs__trig {
padding: 4px;
font-size: 11px;
line-height: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.cs.is-demo .cs__pop,
.cs.is-demo .cs__hl,
.cs.is-demo .cs__face,
.cs.is-demo .cs__dot,
.cs.is-demo .cs__priceline {
animation: none !important;
}
.cs:not(.is-demo) .cs__pop,
.cs:not(.is-demo) .cs__hl {
transition: none !important;
}
.cs.is-demo .cs__pop {
transform: none;
opacity: 1;
}
.cs.is-demo .cs__face[data-cur=usd] {
opacity: 1;
}
.cs.is-demo .cs__opt[data-cur=usd] .cs__dot {
opacity: 1;
}
}// 실제 동작: 트리거의 aria-expanded 로 목록을 열고 닫는다. 고르면 뿌리의 data-cur 가 바뀌고
// 그 값에 걸린 CSS 가 금액 얼굴과 표시 점을 한꺼번에 갈아 끼운다.
// JS 가 넣는 문자열은 라틴 코드와 통화 기호뿐 — 한국어를 만들지 않는다
var root = document.querySelector('.cs');
var trig = document.getElementById('cs-trig');
var code = document.getElementById('cs-code');
var tsym = document.getElementById('cs-tsym');
var opts = [].slice.call(document.querySelectorAll('.cs__opt'));
function open(on) { trig.setAttribute('aria-expanded', String(on)); }
function pick(li) {
root.classList.remove('is-demo');
root.setAttribute('data-cur', li.getAttribute('data-cur'));
opts.forEach(function (o) { o.setAttribute('aria-selected', String(o === li)); });
code.textContent = li.getAttribute('data-code');
tsym.textContent = li.getAttribute('data-sym');
open(false);
trig.focus({ preventScroll: true });
}
trig.addEventListener('click', function () {
root.classList.remove('is-demo');
open(trig.getAttribute('aria-expanded') !== 'true');
});
opts.forEach(function (li) {
li.addEventListener('click', function () { pick(li); });
li.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); pick(li); }
});
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && trig.getAttribute('aria-expanded') === 'true') {
open(false);
trig.focus({ preventScroll: true });
}
});import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./CurrencySwitch.module.scss";
export type Currency = { id: string; code: string; sym: string; amt: string };
export type CurrencySwitchProps = {
planName?: string;
note?: string;
pickLabel?: string;
unit?: string;
items?: Currency[];
duration?: number;
easing?: string;
color?: string;
};
// React 이벤트 타입은 import 하지 않는다 — 필요한 모양만 여기서 적는다
type KeyEvt = { key: string; preventDefault(): void };
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
const DEFAULT_ITEMS: Currency[] = [
{ id: "usd", code: "USD", sym: "$", amt: "29" },
{ id: "krw", code: "KRW", sym: "₩", amt: "39,000" },
{ id: "jpy", code: "JPY", sym: "¥", amt: "4,400" },
{ id: "eur", code: "EUR", sym: "€", amt: "27" },
];
export default function CurrencySwitch({
planName = "Standard plan",
note = "Tax not included",
pickLabel = "Pick a currency",
unit = "/ mo",
items = DEFAULT_ITEMS,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#4cd4a6",
}: CurrencySwitchProps) {
const [demo, setDemo] = useState(true);
const [open, setOpen] = useState(false);
const [cur, setCur] = useState(items[0].id);
const trigRef = useRef<HTMLButtonElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const now = items.find((c) => c.id === cur) || items[0];
const at = Math.max(0, items.findIndex((c) => c.id === cur));
function toggle() {
setDemo(false);
setOpen((v) => !v);
}
function pick(c: Currency) {
setDemo(false);
setCur(c.id);
setOpen(false);
trigRef.current?.focus({ preventScroll: true });
}
function onKey(e: KeyEvt) {
if (e.key === "Escape" && open) {
e.preventDefault();
setOpen(false);
trigRef.current?.focus({ preventScroll: true });
}
}
return (
<div
className={`${styles.cs} ${demo ? styles.isDemo : ""}`}
data-cur={cur}
style={vars}
onKeyDown={onKey}
>
<div className={styles.left}>
<p className={styles.plan}>{planName}</p>
<div className={styles.priceline} aria-live="polite">
{items.map((c) => (
<span key={c.id} className={styles.face} data-cur={c.id} data-on={!demo && c.id === cur ? "1" : "0"}>
<span className={styles.sym}>{c.sym}</span>
<span className={styles.amt}>{c.amt}</span>
<span className={styles.unit}>{unit}</span>
</span>
))}
</div>
<p className={styles.note}>{note}</p>
</div>
<div className={styles.pick}>
<button
ref={trigRef}
className={styles.trig}
type="button"
aria-expanded={open}
aria-haspopup="listbox"
aria-label={pickLabel}
onClick={toggle}
>
<span className={styles.code}>{now.code}</span>
<span className={styles.tsym}>{now.sym}</span>
<span className={styles.caret} aria-hidden="true">
<svg viewBox="0 0 256 256" width="14" height="14">
<g fill="currentColor">
<path d="m208 96l-80 80l-80-80Z" opacity=".2" />
<path d="M215.39 92.94A8 8 0 0 0 208 88H48a8 8 0 0 0-5.66 13.66l80 80a8 8 0 0 0 11.32 0l80-80a8 8 0 0 0 1.73-8.72M128 164.69L67.31 104h121.38Z" />
</g>
</svg>
</span>
</button>
<div className={styles.pop} data-open={open ? "1" : "0"}>
{/* 표시 띠만 부드럽게 미끄러진다 — 컷 옆에서 움직인 프레임을 채우는 몫 */}
<span className={styles.hl} style={{ transform: `translateY(calc(var(--row) * ${at}))` }} aria-hidden="true" />
<ul className={styles.list} role="listbox" aria-label={pickLabel}>
{items.map((c) => (
<li
key={c.id}
className={styles.opt}
role="option"
aria-selected={!demo && c.id === cur}
tabIndex={0}
data-cur={c.id}
onClick={() => pick(c)}
onKeyDown={(e: KeyEvt) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
pick(c);
}
}}
>
<span className={styles.dot} data-on={!demo && c.id === cur ? "1" : "0"} aria-hidden="true" />
<span className={styles.oc}>{c.code}</span>
<span className={styles.os}>{c.sym}</span>
</li>
))}
</ul>
</div>
</div>
</div>
);
}02 通貨を変えると記号と金額が同時に変わる — 計測 累積面積 8.975% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · 料金表 デザイン 実装9選 — 切り替えも計算も動く
このデモが属する記事
料金表 デザイン 実装9選 — 切り替えも計算も動く料金表 デザインで実際に効くのはカードの形ではなく、その上で起きるインタラクションです。9 つはすべてライブラリなしの素の JS と SCSS だけで動き、最初の 1 つは支払い周期を切り替えたときに数字が実際に変わる、その動作だけを入れま この記事のデモをすべて見る


