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 원작 · zip 에 SCSS·React 포함 · 요금제 디자인 인터랙션 9종 — 토글부터 결제 버튼까지
이 데모가 속한 글
요금제 디자인 인터랙션 9종 — 토글부터 결제 버튼까지요금제 디자인에서 실제로 일하는 건 카드 모양이 아니라 그 위에서 벌어지는 인터랙션입니다. 아홉 개 전부 라이브러리 없이 바닐라 JS와 SCSS로만 돌아가고, 첫 번째는 결제 주기를 바꿀 때 숫자가 진짜 바뀌는 그 이 글의 데모 전부 보기


