02Currency switch that changes symbol and amount
Picking a currency from the list swaps the trigger code, the symbol, and the converted amount at once, and the open list keeps reserved room below so it never spills off the stage.
Best forPrice headers on products sold in several countries, region-specific checkout notes
aria-expandedaria-selectedpadding-bottom

<main class="stage">
<div class="cs is-demo" data-cur="usd">
<div class="cs__left">
<p class="cs__plan">Standard 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">/ mo</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">/ mo</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">/ mo</span></span>
<span class="cs__face" data-cur="eur"><span class="cs__sym">€</span><span class="cs__amt">27</span><span class="cs__unit">/ mo</span></span>
</div>
<p class="cs__note">Tax not included</p>
</div>
<div class="cs__pick">
<button class="cs__trig" type="button" id="cs-trig" aria-expanded="false" aria-haspopup="listbox" aria-label="Pick a currency">
<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="Pick a currency">
<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 Currency switch that changes symbol and amount — Measured area 8.975% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · Pricing Table Design: 9 Live Interactions
The post this demo lives in
Pricing Table Design: 9 Live InteractionsIn pricing table design, the work happens on top of the cards, not inside them. All nine run on vanilla JS and SCSS, and See all demos in this post
If you like this effect - similar demos
Landing sections05 Pricing FAQ — an accordion that remeasures the list height by how many entries are open9 Pricing Page Design Sections (Copy-Paste)
Landing sections05 Objection-handling FAQ that arms the CTALanding Page Conversion: 9 Sections That Convert
Landing sections04 Profile detail — a card that measures bio height to decide whether read-more even appears9 Team Page Design Patterns (Copy-Paste)