04국가 코드 선택 전화번호 입력창
왼쪽 국기 버튼을 눌러 국가를 고르면 국가번호가 바뀌고, 번호 칸은 그 나라 자릿수에 맞춰 자동 포맷됩니다.
쓰임해외 배송 주문 전화번호, 글로벌 회원가입
국기 드롭다운국가별 자릿수키보드로 국가 탐색

<main class="stage">
<div class="phone is-demo" id="wrap">
<span class="phone__label" id="phone-label">전화번호</span>
<div class="phone__row">
<div class="phone__trigger-wrap">
<button type="button" class="phone__trigger" id="trigger"
aria-haspopup="listbox" aria-expanded="false" aria-controls="listbox"
aria-label="국가 선택, 현재 대한민국">
<span class="phone__flag" aria-hidden="true">🇰🇷</span>
<span class="phone__dial">+82</span>
<span class="phone__chevron" aria-hidden="true"></span>
</button>
<ul class="phone__listbox" id="listbox" role="listbox" aria-label="국가 목록" tabindex="-1" hidden>
<li role="option" id="opt-kr" class="phone__option" data-code="kr" aria-selected="true">
<span aria-hidden="true">🇰🇷</span> 대한민국 <span class="phone__option-dial">+82</span>
</li>
<li role="option" id="opt-us" class="phone__option" data-code="us" aria-selected="false">
<span aria-hidden="true">🇺🇸</span> 미국 <span class="phone__option-dial">+1</span>
</li>
<li role="option" id="opt-jp" class="phone__option" data-code="jp" aria-selected="false">
<span aria-hidden="true">🇯🇵</span> 일본 <span class="phone__option-dial">+81</span>
</li>
</ul>
</div>
<input class="phone__input" id="number" type="text" inputmode="numeric" placeholder="10 1234 5678" aria-labelledby="phone-label" aria-describedby="phone-hint">
</div>
<p class="phone__hint" id="phone-hint">화살표 키로 국가 선택 · Enter로 확정</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-quick; // 목록 열림/닫힘 시간
$easing: $ease-out; // 빠르게 시작해 부드럽게 멈춤
$color: $subject-blue; // 트리거 포커스·선택 강조 색
// ── 데모 무대 ──────────────────────────────────────────
@include stage($stage-paper);
.phone {
box-sizing: border-box;
width: min(296px, 88vw);
background: $subject-cream;
border-radius: $r-card;
padding: $sp-5;
box-shadow: $shadow-raised;
&__label {
display: block;
margin: 0 0 $sp-2;
font-weight: 700;
font-size: 13px;
line-height: 1;
font-family: inherit;
color: $stage-ink;
}
&__row {
display: flex;
gap: $sp-2;
}
&__trigger-wrap {
position: relative;
flex: 0 0 auto;
}
&__trigger {
box-sizing: border-box;
height: 100%;
display: flex;
align-items: center;
gap: $sp-1;
border: 2px solid transparent;
border-radius: $r-control;
padding: $sp-3 $sp-2;
background: #fff;
font-weight: 600;
font-size: clamp(13px, 3.6vw, 15px);
font-family: inherit;
color: $stage-ink;
white-space: nowrap;
cursor: pointer;
transition: border-color $duration $easing;
&:focus-visible { outline: none; border-color: $color; }
&[aria-expanded="true"] { border-color: $color; }
}
&__flag { font-size: 16px; line-height: 1; }
&__dial { font-variant-numeric: tabular-nums; }
&__chevron {
width: 0;
height: 0;
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-top: 5px solid currentColor;
transition: transform $duration $easing;
}
&__trigger[aria-expanded="true"] &__chevron { transform: rotate(180deg); }
&__input {
box-sizing: border-box;
flex: 1 1 auto;
min-width: 0;
border: 2px solid transparent;
border-radius: $r-control;
padding: $sp-3 $sp-3;
font-weight: 500;
font-size: clamp(14px, 4.2vw, 16px);
font-family: inherit;
color: $stage-ink;
background: #fff;
transition: border-color $duration $easing;
&:focus { outline: none; border-color: $color; }
}
&__hint {
margin: $sp-2 0 0;
font-size: 11px;
line-height: 1.4;
font-family: inherit;
color: rgba($stage-ink, .72);
}
&__listbox {
position: absolute;
top: calc(100% + #{$sp-1});
left: 0;
width: min(190px, 75vw);
max-height: 160px;
overflow-y: auto;
margin: 0;
padding: $sp-1;
list-style: none;
background: #fff;
border-radius: $r-sm;
box-shadow: $shadow-raised;
z-index: 10;
transform-origin: top; // 규칙8: 트리거 아래변에서 펼침
opacity: 0;
transform: scaleY(.9) translateY(-4px);
pointer-events: none;
&.is-open {
opacity: 1;
transform: scaleY(1) translateY(0);
pointer-events: auto;
transition: opacity $duration $easing, transform $duration $easing;
}
}
&__option {
display: flex;
align-items: center;
gap: $sp-1;
padding: $sp-2 $sp-2;
border-radius: $r-xs;
font-size: 13px;
font-weight: 500;
font-family: inherit;
color: $stage-ink;
cursor: pointer;
&-dial {
margin-left: auto;
font-variant-numeric: tabular-nums;
opacity: .7;
}
&--active { background: rgba($color, .12); }
&[aria-selected="true"] { font-weight: 700; color: $color; }
}
}
@media (prefers-reduced-motion: reduce) {
.phone__listbox { transition: none !important; }
.phone__trigger, .phone__input, .phone__chevron { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.phone {
box-sizing: border-box;
width: min(296px, 88vw);
background: #fff7e6;
border-radius: 16px;
padding: 20px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.phone__label {
display: block;
margin: 0 0 8px;
font-weight: 700;
font-size: 13px;
line-height: 1;
font-family: inherit;
color: #17141a;
}
.phone__row {
display: flex;
gap: 8px;
}
.phone__trigger-wrap {
position: relative;
flex: 0 0 auto;
}
.phone__trigger {
box-sizing: border-box;
height: 100%;
display: flex;
align-items: center;
gap: 4px;
border: 2px solid transparent;
border-radius: 12px;
padding: 12px 8px;
background: #fff;
font-weight: 600;
font-size: clamp(13px, 3.6vw, 15px);
font-family: inherit;
color: #17141a;
white-space: nowrap;
cursor: pointer;
transition: border-color 220ms ease-out;
}
.phone__trigger:focus-visible {
outline: none;
border-color: #2f6df6;
}
.phone__trigger[aria-expanded=true] {
border-color: #2f6df6;
}
.phone__flag {
font-size: 16px;
line-height: 1;
}
.phone__dial {
font-variant-numeric: tabular-nums;
}
.phone__chevron {
width: 0;
height: 0;
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-top: 5px solid currentColor;
transition: transform 220ms ease-out;
}
.phone__trigger[aria-expanded=true] .phone__chevron {
transform: rotate(180deg);
}
.phone__input {
box-sizing: border-box;
flex: 1 1 auto;
min-width: 0;
border: 2px solid transparent;
border-radius: 12px;
padding: 12px 12px;
font-weight: 500;
font-size: clamp(14px, 4.2vw, 16px);
font-family: inherit;
color: #17141a;
background: #fff;
transition: border-color 220ms ease-out;
}
.phone__input:focus {
outline: none;
border-color: #2f6df6;
}
.phone__hint {
margin: 8px 0 0;
font-size: 11px;
line-height: 1.4;
font-family: inherit;
color: rgba(23, 20, 26, 0.72);
}
.phone__listbox {
position: absolute;
top: calc(100% + 4px);
left: 0;
width: min(190px, 75vw);
max-height: 160px;
overflow-y: auto;
margin: 0;
padding: 4px;
list-style: none;
background: #fff;
border-radius: 8px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
z-index: 10;
transform-origin: top;
opacity: 0;
transform: scaleY(0.9) translateY(-4px);
pointer-events: none;
}
.phone__listbox.is-open {
opacity: 1;
transform: scaleY(1) translateY(0);
pointer-events: auto;
transition: opacity 220ms ease-out, transform 220ms ease-out;
}
.phone__option {
display: flex;
align-items: center;
gap: 4px;
padding: 8px 8px;
border-radius: 4px;
font-size: 13px;
font-weight: 500;
font-family: inherit;
color: #17141a;
cursor: pointer;
}
.phone__option-dial {
margin-left: auto;
font-variant-numeric: tabular-nums;
opacity: 0.7;
}
.phone__option--active {
background: rgba(47, 109, 246, 0.12);
}
.phone__option[aria-selected=true] {
font-weight: 700;
color: #2f6df6;
}
@media (prefers-reduced-motion: reduce) {
.phone__listbox {
transition: none !important;
}
.phone__trigger, .phone__input, .phone__chevron {
transition: none;
}
}(function () {
var COUNTRIES = {
kr: { name: '대한민국', flag: '🇰🇷', dial: '+82', groups: [3, 4, 4], sample: '1012345678' },
us: { name: '미국', flag: '🇺🇸', dial: '+1', groups: [3, 3, 4], sample: '2025551234' },
jp: { name: '일본', flag: '🇯🇵', dial: '+81', groups: [2, 4, 4], sample: '9012345678' }
};
var ORDER = ['kr', 'us', 'jp'];
var wrap = document.getElementById('wrap');
var trigger = document.getElementById('trigger');
var listbox = document.getElementById('listbox');
var input = document.getElementById('number');
var options = Array.prototype.slice.call(listbox.querySelectorAll('.phone__option'));
var current = 'kr';
var active = 0; // 키보드로 강조된 옵션 인덱스 (열려있을 때만 의미 있음)
var open = false;
var hideTimer = null;
function maxDigits(code) {
var g = COUNTRIES[code].groups, total = 0;
for (var i = 0; i < g.length; i++) total += g[i];
return total;
}
function formatDigits(digits, groups) {
var out = [], i = 0;
for (var g = 0; g < groups.length && i < digits.length; g++) {
out.push(digits.slice(i, i + groups[g]));
i += groups[g];
}
return out.join(' ');
}
function reformatInput() {
var digits = input.value.replace(/\D/g, '').slice(0, maxDigits(current));
input.value = formatDigits(digits, COUNTRIES[current].groups);
}
function highlight(i) {
active = i;
options.forEach(function (o, idx) { o.classList.toggle('phone__option--active', idx === i); });
trigger.setAttribute('aria-activedescendant', options[i].id);
}
function openListbox() {
if (open) return;
open = true;
clearTimeout(hideTimer);
listbox.hidden = false;
void listbox.offsetWidth; // 강제 리플로우 — hidden 해제와 애니메이션을 분리
listbox.classList.add('is-open');
trigger.setAttribute('aria-expanded', 'true');
highlight(ORDER.indexOf(current));
}
function closeListbox(returnFocus) {
if (!open) return;
open = false;
listbox.classList.remove('is-open');
trigger.setAttribute('aria-expanded', 'false');
trigger.removeAttribute('aria-activedescendant');
options.forEach(function (o) { o.classList.remove('phone__option--active'); });
hideTimer = setTimeout(function () { if (!open) listbox.hidden = true; }, 220);
if (returnFocus) trigger.focus({ preventScroll: true });
}
function selectCountry(code) {
current = code;
options.forEach(function (o) { o.setAttribute('aria-selected', String(o.dataset.code === code)); });
var c = COUNTRIES[code];
trigger.querySelector('.phone__flag').textContent = c.flag;
trigger.querySelector('.phone__dial').textContent = c.dial;
trigger.setAttribute('aria-label', '국가 선택, 현재 ' + c.name);
reformatInput();
}
trigger.addEventListener('click', function () {
if (open) closeListbox(true); else openListbox();
});
trigger.addEventListener('keydown', function (e) {
if (!open) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openListbox();
}
return;
}
if (e.key === 'ArrowDown') { e.preventDefault(); highlight((active + 1) % options.length); }
else if (e.key === 'ArrowUp') { e.preventDefault(); highlight((active - 1 + options.length) % options.length); }
else if (e.key === 'Enter') { e.preventDefault(); selectCountry(options[active].dataset.code); closeListbox(true); }
else if (e.key === 'Escape') { e.preventDefault(); closeListbox(true); }
else if (e.key === 'Tab') { closeListbox(false); }
});
options.forEach(function (o, idx) {
o.addEventListener('click', function () { selectCountry(o.dataset.code); closeListbox(true); });
o.addEventListener('pointerenter', function () { highlight(idx); });
});
document.addEventListener('pointerdown', function (e) {
if (open && !wrap.contains(e.target)) closeListbox(false);
});
input.addEventListener('input', reformatInput);
// ── 실사용자가 손대면 데모를 영구 정지 (캡처 단계 — 실제 동작을 막지 않는다) ──
wrap.addEventListener('pointerdown', stopDemo, true);
wrap.addEventListener('keydown', stopDemo, true);
function stopDemo() {
if (!wrap.classList.contains('is-demo')) return;
wrap.classList.remove('is-demo');
clearTimeout(demoTimer);
}
// ── 자동 데모 루프: 실제 코드 경로(openListbox·selectCountry·input 이벤트)를 그대로 돈다 ──
var demoTimer = null;
var orderIdx = 0;
function typewrite(code, i, cb) {
if (!wrap.classList.contains('is-demo')) return;
var sample = COUNTRIES[code].sample;
if (i <= sample.length) {
input.value = formatDigits(sample.slice(0, i), COUNTRIES[code].groups);
demoTimer = setTimeout(function () { typewrite(code, i + 1, cb); }, 90);
} else {
demoTimer = setTimeout(cb, 900);
}
}
function demoStep() {
if (!wrap.classList.contains('is-demo')) return;
var code = ORDER[orderIdx % ORDER.length];
orderIdx++;
openListbox();
highlight(ORDER.indexOf(code));
demoTimer = setTimeout(function () {
if (!wrap.classList.contains('is-demo')) return;
selectCountry(code);
closeListbox(false);
demoTimer = setTimeout(function () {
typewrite(code, 1, function () {
input.value = '';
demoTimer = setTimeout(demoStep, 260);
});
}, 260);
}, 500);
}
demoTimer = setTimeout(demoStep, 500);
})();import { useEffect, useRef, useState } from "react";
import styles from "./PhoneCountryInput.module.scss";
type CountryCode = "kr" | "us" | "jp";
type Country = {
name: string;
flag: string;
dial: string;
groups: number[];
};
const COUNTRIES: Record<CountryCode, Country> = {
kr: { name: "대한민국", flag: "🇰🇷", dial: "+82", groups: [3, 4, 4] },
us: { name: "미국", flag: "🇺🇸", dial: "+1", groups: [3, 3, 4] },
jp: { name: "일본", flag: "🇯🇵", dial: "+81", groups: [2, 4, 4] },
};
const ORDER: CountryCode[] = ["kr", "us", "jp"];
function formatDigits(digits: string, groups: number[]): string {
const out: string[] = [];
let i = 0;
for (let g = 0; g < groups.length && i < digits.length; g++) {
out.push(digits.slice(i, i + groups[g]));
i += groups[g];
}
return out.join(" ");
}
function maxDigits(groups: number[]): number {
return groups.reduce((a, b) => a + b, 0);
}
export default function PhoneCountryInput() {
const [country, setCountry] = useState<CountryCode>("kr");
const [number, setNumber] = useState("");
const [open, setOpen] = useState(false);
const [active, setActive] = useState(0);
const wrapRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const current = COUNTRIES[country];
const openList = () => {
setActive(ORDER.indexOf(country));
setOpen(true);
};
const closeList = (returnFocus: boolean) => {
setOpen(false);
if (returnFocus) triggerRef.current?.focus({ preventScroll: true });
};
const selectCountry = (code: CountryCode) => {
setCountry(code);
setNumber((prev) => {
const digits = prev.replace(/\D/g, "").slice(0, maxDigits(COUNTRIES[code].groups));
return formatDigits(digits, COUNTRIES[code].groups);
});
};
const onNumberChange = (e: any) => {
const digits = String(e.target.value).replace(/\D/g, "").slice(0, maxDigits(current.groups));
setNumber(formatDigits(digits, current.groups));
};
const onTriggerKeyDown = (e: any) => {
if (!open) {
if (["ArrowDown", "ArrowUp", "Enter", " "].indexOf(e.key) !== -1) {
e.preventDefault();
openList();
}
return;
}
if (e.key === "ArrowDown") {
e.preventDefault();
setActive((a) => (a + 1) % ORDER.length);
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActive((a) => (a - 1 + ORDER.length) % ORDER.length);
} else if (e.key === "Enter") {
e.preventDefault();
selectCountry(ORDER[active]);
closeList(true);
} else if (e.key === "Escape") {
e.preventDefault();
closeList(true);
}
};
useEffect(() => {
if (!open) return;
const onDocPointerDown = (e: PointerEvent) => {
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) closeList(false);
};
document.addEventListener("pointerdown", onDocPointerDown);
return () => document.removeEventListener("pointerdown", onDocPointerDown);
}, [open]);
return (
<div className={styles.phone} ref={wrapRef}>
<span className={styles.label} id="phone-label">전화번호</span>
<div className={styles.row}>
<div className={styles.triggerWrap}>
<button
ref={triggerRef}
type="button"
className={styles.trigger}
aria-haspopup="listbox"
aria-expanded={open}
aria-controls="phone-listbox"
aria-activedescendant={open ? `phone-opt-${ORDER[active]}` : undefined}
aria-label={`국가 선택, 현재 ${current.name}`}
onClick={() => (open ? closeList(true) : openList())}
onKeyDown={onTriggerKeyDown}
>
<span className={styles.flag} aria-hidden="true">{current.flag}</span>
<span className={styles.dial}>{current.dial}</span>
<span className={styles.chevron + (open ? " " + styles.chevronOpen : "")} aria-hidden="true" />
</button>
{open && (
<ul className={styles.listbox} id="phone-listbox" role="listbox" aria-label="국가 목록">
{ORDER.map((code, i) => (
<li
key={code}
role="option"
id={`phone-opt-${code}`}
aria-selected={code === country}
className={styles.option + (i === active ? " " + styles.optionActive : "")}
onClick={() => {
selectCountry(code);
closeList(true);
}}
onPointerEnter={() => setActive(i)}
>
<span aria-hidden="true">{COUNTRIES[code].flag}</span> {COUNTRIES[code].name}{" "}
<span className={styles.optionDial}>{COUNTRIES[code].dial}</span>
</li>
))}
</ul>
)}
</div>
<input
className={styles.input}
type="text"
inputMode="numeric"
placeholder="10 1234 5678"
aria-labelledby="phone-label"
value={number}
onChange={onNumberChange}
/>
</div>
</div>
);
}04 국가 코드 선택 전화번호 입력창 — 측정 누적면적 14.364% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 회원가입 폼 디자인 입력창 9가지 — 붙이면 바로 되는 코드
이 데모가 속한 글
회원가입 폼 디자인 입력창 9가지 — 붙이면 바로 되는 코드회원가입 폼 디자인에서 사람이 처음으로 손대는 곳은 버튼이 아니라 입력창입니다. 여기 아홉 개는 이메일 칸부터 지우기 버튼까지 회원가입 화면에 실제로 들어가는 입력창이고, 코드를 붙이면 그대로 움직입니다. 이 글의 데모 전부 보기


