회원가입 폼 디자인 입력창 9가지 — 붙이면 바로 되는 코드
회원가입 폼 디자인에서 사람이 처음으로 손대는 곳은 버튼이 아니라 입력창입니다. 여기 아홉 개는 이메일 칸부터 지우기 버튼까지 회원가입 화면에 실제로 들어가는 입력창이고, 코드를 붙이면 그대로 움직입니다.
02 floating-label-input
03 date-input-auto-slash
04 phone-input-country-select
05 address-autocomplete-style
06 multi-select-combobox
07 mention-hashtag-highlight-input
08 character-counter-input
09 input-clear-button
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 아이콘·단위 붙는 입력창
- 02 뜨는 라벨 입력창
- 03 날짜 자동 슬래시 입력창
- 04 국가 코드 선택 전화번호 입력창
- 05 주소 자동완성 스타일 입력창
- 06 다중 선택 콤보박스
- 07 멘션·해시태그 강조 입력창
- 08 글자수 카운터 입력창
- 09 지우기(X) 버튼 입력창
늘어놓은 순서는 화면에 나오는 순서입니다. 맨 위의 이메일 칸(01)은 아이콘과 단위로 무엇을 적는 칸인지 먼저 알려 주고, 이름 칸(02)은 라벨이 위로 떠서 자리를 아끼고, 생년월일(03)과 전화번호(04)는 숫자를 사람이 읽는 모양으로 바꿔 줍니다. 그다음은 주소 입력창(05)과 관심 분야(06)처럼 목록에서 골라 채우는 칸이고, 마지막 셋은 소개글을 쓰는 동안 일어나는 일들입니다. 멘션과 해시태그(07)는 쓰는 중에 색이 들어오고, 글자수(08)는 한도를 지켜 주고, 지우기 버튼(09)은 다시 쓰고 싶을 때 한 번에 비워 줍니다. 목록이 열리는 셋(04·05·06)은 role="listbox"와 aria-activedescendant로 화살표 키가 실제로 듣고, Escape는 04·05·06·09 넷이 받습니다. 같은 input 기둥에서 결제·메모까지 폭을 넓힌 편이 입력창 디자인 9가지이고, 적힌 값의 맞고 틀림만 따로 떼어 본 편이 폼 유효성 검사 UI 9종입니다.
01아이콘·단위 붙는 입력창
회원가입 단계 ui에서 무엇이 보이나
왼쪽의 이메일 아이콘과 오른쪽의 kg 글자가 절대 위치로 붙어 있어, 칸에 손대기 전에 무엇을 적는지 알려 줍니다.
<main class="stage">
<div class="affix is-demo" id="wrap">
<div class="affix__field">
<label class="affix__label" for="email-input">이메일</label>
<div class="affix__control">
<svg class="affix__icon" aria-hidden="true" viewBox="0 0 20 20" width="16" height="16" fill="none">
<rect x="2" y="4" width="16" height="12" rx="2" stroke="currentColor" stroke-width="1.6"/>
<path d="M2.5 5L10 11L17.5 5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<input class="affix__input affix__input--prefix" id="email-input" type="text" placeholder="[email protected]">
</div>
</div>
<div class="affix__field">
<label class="affix__label" for="weight-input">몸무게</label>
<div class="affix__control">
<input class="affix__input affix__input--suffix" id="weight-input" type="text" placeholder="0">
<span class="affix__unit" aria-hidden="true">kg</span>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-quick; // 테두리 색 전환 시간
$easing: $ease-out; // 포커스 이징
$color: $subject-blue; // 포커스 강조 색
// ── 데모 무대 ──────────────────────────────────────────
@include stage($stage-ink);
.affix {
box-sizing: border-box;
width: min(280px, 86vw);
background: $subject-cream;
border-radius: $r-card;
padding: $sp-4;
box-shadow: $shadow-raised;
display: flex;
flex-direction: column;
gap: $sp-4;
&__field {
display: flex;
flex-direction: column;
gap: $sp-1;
}
&__label {
display: block;
font-weight: 700;
font-size: 12px;
line-height: 1;
font-family: inherit;
color: $stage-ink;
}
&__control {
position: relative;
display: flex;
align-items: center;
}
&__icon {
position: absolute;
left: $sp-3;
color: rgba(23, 20, 26, .45);
pointer-events: none;
transition: color $duration $easing;
}
&__unit {
position: absolute;
right: $sp-3;
font-size: 13px;
font-weight: 600;
font-family: inherit;
color: rgba(23, 20, 26, .45);
pointer-events: none;
transition: color $duration $easing;
}
&__input {
box-sizing: border-box;
width: 100%;
border: 2px solid transparent;
border-radius: $r-control;
padding: $sp-3 $sp-4;
font-weight: 500;
font-size: clamp(14px, 4.2vw, 16px);
font-family: inherit;
color: $stage-ink;
background: #fff;
transition: border-color $duration $easing;
&--prefix { padding-left: $sp-10; }
&--suffix { padding-right: $sp-8; }
&:focus {
outline: none;
border-color: $color;
& ~ .affix__unit { color: $color; }
}
}
&__control:focus-within &__icon { color: $color; }
}
@media (prefers-reduced-motion: reduce) {
.affix__input,
.affix__icon,
.affix__unit {
transition: none;
}
}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);
}
.affix {
box-sizing: border-box;
width: min(280px, 86vw);
background: #fff7e6;
border-radius: 16px;
padding: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
display: flex;
flex-direction: column;
gap: 16px;
}
.affix__field {
display: flex;
flex-direction: column;
gap: 4px;
}
.affix__label {
display: block;
font-weight: 700;
font-size: 12px;
line-height: 1;
font-family: inherit;
color: #17141a;
}
.affix__control {
position: relative;
display: flex;
align-items: center;
}
.affix__icon {
position: absolute;
left: 12px;
color: rgba(23, 20, 26, 0.45);
pointer-events: none;
transition: color 220ms ease-out;
}
.affix__unit {
position: absolute;
right: 12px;
font-size: 13px;
font-weight: 600;
font-family: inherit;
color: rgba(23, 20, 26, 0.45);
pointer-events: none;
transition: color 220ms ease-out;
}
.affix__input {
box-sizing: border-box;
width: 100%;
border: 2px solid transparent;
border-radius: 12px;
padding: 12px 16px;
font-weight: 500;
font-size: clamp(14px, 4.2vw, 16px);
font-family: inherit;
color: #17141a;
background: #fff;
transition: border-color 220ms ease-out;
}
.affix__input--prefix {
padding-left: 40px;
}
.affix__input--suffix {
padding-right: 32px;
}
.affix__input:focus {
outline: none;
border-color: #2f6df6;
}
.affix__input:focus ~ .affix__unit {
color: #2f6df6;
}
.affix__control:focus-within .affix__icon {
color: #2f6df6;
}
@media (prefers-reduced-motion: reduce) {
.affix__input,
.affix__icon,
.affix__unit {
transition: none;
}
}(function () {
var wrap = document.getElementById('wrap');
var emailInput = document.getElementById('email-input');
var weightInput = document.getElementById('weight-input');
function stopDemo() {
if (!wrap.classList.contains('is-demo')) return;
wrap.classList.remove('is-demo');
clearInterval(timer);
}
emailInput.addEventListener('pointerdown', stopDemo);
emailInput.addEventListener('keydown', stopDemo);
weightInput.addEventListener('pointerdown', stopDemo);
weightInput.addEventListener('keydown', stopDemo);
function fireInput(el) {
el.dispatchEvent(new Event('input', { bubbles: true }));
}
// 각 단계: target(어느 input) · text(칠 문자열) · hold(다 친 뒤 멈춤 ms)
var script = [
{ el: emailInput, text: '[email protected]', hold: 900 },
{ el: weightInput, text: '58', hold: 900 }
];
var stepIdx = 0, chars = '', phase = 'focus', pauseUntil = 0;
var timer = setInterval(function () {
var now = Date.now();
if (now < pauseUntil) return;
var step = script[stepIdx];
if (phase === 'focus') {
step.el.focus({ preventScroll: true });
phase = 'type';
return;
}
if (phase === 'type') {
if (chars.length < step.text.length) {
chars += step.text[chars.length];
step.el.value = chars;
fireInput(step.el);
} else {
phase = 'hold';
pauseUntil = now + step.hold;
}
return;
}
if (phase === 'hold') {
step.el.value = '';
fireInput(step.el);
step.el.blur();
chars = '';
stepIdx = (stepIdx + 1) % script.length;
phase = 'focus';
pauseUntil = now + 220;
}
}, 140);
})();import { useState } from "react";
import styles from "./IconAffixInput.module.scss";
export type IconAffixInputProps = {
emailLabel?: string;
weightLabel?: string;
};
export default function IconAffixInput({
emailLabel = "이메일",
weightLabel = "몸무게",
}: IconAffixInputProps) {
const [email, setEmail] = useState("");
const [weight, setWeight] = useState("");
return (
<div className={styles.affix}>
<div className={styles.field}>
<label className={styles.label} htmlFor="email-input">
{emailLabel}
</label>
<div className={styles.control}>
<svg
className={styles.icon}
aria-hidden="true"
viewBox="0 0 20 20"
width="16"
height="16"
fill="none"
>
<rect x="2" y="4" width="16" height="12" rx="2" stroke="currentColor" strokeWidth="1.6" />
<path
d="M2.5 5L10 11L17.5 5"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<input
className={`${styles.input} ${styles.inputPrefix}`}
id="email-input"
type="text"
placeholder="[email protected]"
value={email}
onChange={(e: any) => setEmail(e.target.value)}
/>
</div>
</div>
<div className={styles.field}>
<label className={styles.label} htmlFor="weight-input">
{weightLabel}
</label>
<div className={styles.control}>
<input
className={`${styles.input} ${styles.inputSuffix}`}
id="weight-input"
type="text"
placeholder="0"
value={weight}
onChange={(e: any) => setWeight(e.target.value)}
/>
<span className={styles.unit} aria-hidden="true">
kg
</span>
</div>
</div>
</div>
);
}01 · 아이콘·단위 붙는 입력창 — 측정 누적면적 1.326% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
포커스가 오면 2px 테두리와 아이콘·단위 색이 같은 색으로 바뀌고, 아이콘과 단위는 aria-hidden="true"에 pointer-events: none이라 클릭을 가로채지 않습니다.
&__input {
box-sizing: border-box;
width: 100%;
border: 2px solid transparent;
border-radius: $r-control;
padding: $sp-3 $sp-4;
font-weight: 500;
font-size: clamp(14px, 4.2vw, 16px);
font-family: inherit;
color: $stage-ink;
background: #fff;
transition: border-color $duration $easing;
&--prefix { padding-left: $sp-10; }
&--suffix { padding-right: $sp-8; }
&:focus {
outline: none;
border-color: $color;
& ~ .affix__unit { color: $color; }
}
}
02뜨는 라벨 입력창
화면에서 일어나는 일
빈 칸에서는 라벨이 칸 안 가운데 놓여 있다가, 포커스를 받거나 글자가 하나라도 들어차면 translateY(-14px) scale(.78)로 위로 떠서 작아집니다.

<main class="stage">
<div class="fl is-demo" id="wrap">
<div class="fl__control">
<input class="fl__input" id="fl-name" type="text" placeholder=" ">
<label class="fl__label" for="fl-name">이름</label>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-quick; // 라벨이 뜨는 시간
$easing: $ease-spring; // 스프링 이징
$color: $subject-lilac; // 포커스·라벨 강조 색
// ── 데모 무대 ──────────────────────────────────────────
@include stage($stage-yellow);
.fl {
box-sizing: border-box;
width: min(260px, 82vw);
background: #fff;
border-radius: $r-card;
padding: $sp-5;
box-shadow: $shadow-raised;
&__control {
position: relative;
}
&__input {
box-sizing: border-box;
width: 100%;
border: 2px solid rgba(23, 20, 26, .12);
border-radius: $r-control;
padding: $sp-5 $sp-4 $sp-2;
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;
}
}
&__label {
position: absolute;
left: $sp-4;
top: 50%;
transform: translateY(-50%) scale(1);
transform-origin: left center; // 규칙8: 라벨 왼쪽 붙어 축소
font-size: 16px;
font-weight: 500;
font-family: inherit;
color: rgba(23, 20, 26, .48);
pointer-events: none;
transition: transform $duration $easing, color $duration $easing;
}
&__input:focus + &__label,
&__input:not(:placeholder-shown) + &__label {
transform: translateY(-14px) scale(.78);
color: $color;
}
}
@media (prefers-reduced-motion: reduce) {
.fl__input,
.fl__label {
transition: none;
}
}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);
}
.fl {
box-sizing: border-box;
width: min(260px, 82vw);
background: #fff;
border-radius: 16px;
padding: 20px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.fl__control {
position: relative;
}
.fl__input {
box-sizing: border-box;
width: 100%;
border: 2px solid rgba(23, 20, 26, 0.12);
border-radius: 12px;
padding: 20px 16px 8px;
font-weight: 500;
font-size: clamp(14px, 4.2vw, 16px);
font-family: inherit;
color: #17141a;
background: #fff;
transition: border-color 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.fl__input:focus {
outline: none;
border-color: #b9a0ff;
}
.fl__label {
position: absolute;
left: 16px;
top: 50%;
transform: translateY(-50%) scale(1);
transform-origin: left center;
font-size: 16px;
font-weight: 500;
font-family: inherit;
color: rgba(23, 20, 26, 0.48);
pointer-events: none;
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1), color 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.fl__input:focus + .fl__label, .fl__input:not(:placeholder-shown) + .fl__label {
transform: translateY(-14px) scale(0.78);
color: #b9a0ff;
}
@media (prefers-reduced-motion: reduce) {
.fl__input,
.fl__label {
transition: none;
}
}(function () {
var wrap = document.getElementById('wrap');
var input = document.getElementById('fl-name');
function stopDemo() {
if (!wrap.classList.contains('is-demo')) return;
wrap.classList.remove('is-demo');
clearInterval(timer);
}
input.addEventListener('pointerdown', stopDemo);
input.addEventListener('keydown', stopDemo);
function fireInput() {
input.dispatchEvent(new Event('input', { bubbles: true }));
}
var word = '홍길동';
var chars = '';
var phase = 'focus';
var pauseUntil = 0;
var timer = setInterval(function () {
var now = Date.now();
if (now < pauseUntil) return;
if (phase === 'focus') {
input.focus({ preventScroll: true });
phase = 'type';
return;
}
if (phase === 'type') {
if (chars.length < word.length) {
chars += word[chars.length];
input.value = chars;
fireInput();
} else {
phase = 'hold';
pauseUntil = now + 260;
}
return;
}
if (phase === 'hold') {
phase = 'clear';
return;
}
if (phase === 'clear') {
chars = '';
input.value = '';
fireInput();
input.blur();
phase = 'blurhold';
pauseUntil = now + 220;
return;
}
if (phase === 'blurhold') {
phase = 'focus';
}
}, 90);
})();import { useState } from "react";
import styles from "./FloatingLabelInput.module.scss";
export type FloatingLabelInputProps = {
label?: string;
};
export default function FloatingLabelInput({ label = "이름" }: FloatingLabelInputProps) {
const [value, setValue] = useState("");
return (
<div className={styles.fl}>
<div className={styles.control}>
<input
className={styles.input}
id="fl-name"
type="text"
placeholder=" "
value={value}
onChange={(e: any) => setValue(e.target.value)}
/>
<label className={styles.label} htmlFor="fl-name">
{label}
</label>
</div>
</div>
);
}02 · 뜨는 라벨 입력창 — 측정 누적면적 1.207% · 움직인 프레임 9/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
JS 없이 :focus와 :not(:placeholder-shown) 두 가상 클래스만으로 하며, 칸에 넣어 둔 placeholder=" " 한 칸이 이 트릭의 재료입니다.
&__label {
position: absolute;
left: $sp-4;
top: 50%;
transform: translateY(-50%) scale(1);
transform-origin: left center;
font-size: 16px;
font-weight: 500;
font-family: inherit;
color: rgba(23, 20, 26, .48);
pointer-events: none;
transition: transform $duration $easing, color $duration $easing;
}
&__input:focus + &__label,
&__input:not(:placeholder-shown) + &__label {
transform: translateY(-14px) scale(.78);
color: $color;
}
03날짜 자동 슬래시 입력창
어떤 움직임인가
숫자만 치면 2026/09/11처럼 넉 자리와 두 자리 뒤에 슬래시가 붙습니다.

<main class="stage">
<div class="dt is-demo" id="wrap">
<label class="dt__label" for="dt-input">생년월일</label>
<input class="dt__input" id="dt-input" type="text" inputmode="numeric" placeholder="YYYY/MM/DD" aria-describedby="dt-hint">
<p class="dt__hint" id="dt-hint">숫자만 입력하면 슬래시가 자동으로 붙어요</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-quick; // 테두리 색 전환 시간
$easing: $ease-out; // 포커스 이징
$color: $stage-orange; // 포커스 강조 색
// ── 데모 무대 ──────────────────────────────────────────
@include stage($stage-orange);
.dt {
box-sizing: border-box;
width: min(272px, 84vw);
background: #fff;
border-radius: $r-card;
padding: $sp-4;
box-shadow: $shadow-raised;
&__label {
display: block;
margin: 0 0 $sp-1;
font-weight: 700;
font-size: 12px;
line-height: 1;
font-family: inherit;
color: $stage-ink;
}
&__input {
box-sizing: border-box;
width: 100%;
border: 2px solid transparent;
border-radius: $r-control;
padding: $sp-3 $sp-4;
font-weight: 600;
font-size: clamp(14px, 4.2vw, 18px);
font-family: inherit;
letter-spacing: .04em;
color: $stage-ink;
background: rgba(23, 20, 26, .06);
transition: border-color $duration $easing, background $duration $easing;
&:focus {
outline: none;
border-color: $color;
background: #fff;
}
}
&__hint {
margin: $sp-1 0 0;
font-size: 11px;
line-height: 1.4;
font-family: inherit;
color: rgba($stage-ink, .72);
}
}
@media (prefers-reduced-motion: reduce) {
.dt__input {
transition: none;
}
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.dt {
box-sizing: border-box;
width: min(272px, 84vw);
background: #fff;
border-radius: 16px;
padding: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.dt__label {
display: block;
margin: 0 0 4px;
font-weight: 700;
font-size: 12px;
line-height: 1;
font-family: inherit;
color: #17141a;
}
.dt__input {
box-sizing: border-box;
width: 100%;
border: 2px solid transparent;
border-radius: 12px;
padding: 12px 16px;
font-weight: 600;
font-size: clamp(14px, 4.2vw, 18px);
font-family: inherit;
letter-spacing: 0.04em;
color: #17141a;
background: rgba(23, 20, 26, 0.06);
transition: border-color 220ms ease-out, background 220ms ease-out;
}
.dt__input:focus {
outline: none;
border-color: #ff4d1f;
background: #fff;
}
.dt__hint {
margin: 4px 0 0;
font-size: 11px;
line-height: 1.4;
font-family: inherit;
color: rgba(23, 20, 26, 0.72);
}
@media (prefers-reduced-motion: reduce) {
.dt__input {
transition: none;
}
}(function () {
var wrap = document.getElementById('wrap');
var input = document.getElementById('dt-input');
function format(raw) {
var digits = raw.replace(/\D/g, '').slice(0, 8);
var out = digits.slice(0, 4);
if (digits.length > 4) out += '/' + digits.slice(4, 6);
if (digits.length > 6) out += '/' + digits.slice(6, 8);
return out;
}
input.addEventListener('input', function () {
input.value = format(input.value);
});
function stopDemo() {
if (!wrap.classList.contains('is-demo')) return;
wrap.classList.remove('is-demo');
clearInterval(timer);
}
input.addEventListener('pointerdown', stopDemo);
input.addEventListener('keydown', stopDemo);
var digits = '20260911';
var raw = '';
var phase = 'focus';
var pauseUntil = 0;
var timer = setInterval(function () {
var now = Date.now();
if (now < pauseUntil) return;
if (phase === 'focus') {
input.focus({ preventScroll: true });
phase = 'type';
return;
}
if (phase === 'type') {
if (raw.length < digits.length) {
raw += digits[raw.length];
input.value = raw;
input.dispatchEvent(new Event('input', { bubbles: true }));
} else {
phase = 'hold';
pauseUntil = now + 900;
}
return;
}
if (phase === 'hold') {
raw = '';
input.value = '';
input.dispatchEvent(new Event('input', { bubbles: true }));
input.blur();
phase = 'blurhold';
pauseUntil = now + 500;
return;
}
if (phase === 'blurhold') {
phase = 'focus';
}
}, 140);
})();import { useState } from "react";
import styles from "./DateAutoSlashInput.module.scss";
export type DateAutoSlashInputProps = {
label?: string;
hint?: string;
};
function formatDate(raw: string): string {
const digits = raw.replace(/\D/g, "").slice(0, 8);
let out = digits.slice(0, 4);
if (digits.length > 4) out += "/" + digits.slice(4, 6);
if (digits.length > 6) out += "/" + digits.slice(6, 8);
return out;
}
export default function DateAutoSlashInput({
label = "생년월일",
hint = "숫자만 입력하면 슬래시가 자동으로 붙어요",
}: DateAutoSlashInputProps) {
const [value, setValue] = useState("");
const onChange = (e: any) => {
setValue(formatDate(e.target.value));
};
return (
<div className={styles.dt}>
<label className={styles.label} htmlFor="dt-input">
{label}
</label>
<input
className={styles.input}
id="dt-input"
type="text"
inputMode="numeric"
placeholder="YYYY/MM/DD"
aria-describedby="dt-hint"
value={value}
onChange={onChange}
/>
<p className={styles.hint} id="dt-hint">
{hint}
</p>
</div>
);
}03 · 날짜 자동 슬래시 입력창 — 측정 누적면적 8.603% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
달력을 여는 게 아니라 inputmode="numeric"을 단 글칸이라 모바일에서 숫자 자판이 먼저 뜨고, 8자리를 넘는 숫자는 잘립니다.
function format(raw) {
var digits = raw.replace(/\D/g, '').slice(0, 8);
var out = digits.slice(0, 4);
if (digits.length > 4) out += '/' + digits.slice(4, 6);
if (digits.length > 6) out += '/' + digits.slice(6, 8);
return out;
}
input.addEventListener('input', function () {
input.value = format(input.value);
});
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 포함 · 비밀번호 보기
코드에서 볼 곳
대한민국 +82는 3·4·4, 미국 +1은 3·3·4, 일본 +81은 2·4·4로 끊겨서 같은 열 자리라도 읽는 모양이 다릅니다.
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); }
});
05주소 자동완성 스타일 입력창
화면에서 일어나는 일
주소를 치면 아래에 후보 다섯 줄 중 맞는 것만 남고, 화살표와 Enter로 골라 칸을 채웁니다.

<main class="stage">
<div class="addr is-demo" id="wrap">
<label class="addr__label" for="addr-input">배송지 주소</label>
<div class="addr__field">
<input class="addr__input" id="addr-input" type="text" role="combobox"
aria-expanded="false" aria-controls="addr-listbox" aria-autocomplete="list"
aria-haspopup="listbox" aria-describedby="addr-hint" autocomplete="off"
placeholder="주소를 입력하세요">
<ul class="addr__listbox" id="addr-listbox" role="listbox" aria-label="주소 후보" hidden></ul>
</div>
<p class="addr__hint" id="addr-hint">화살표 키로 고르고 Enter로 채우기</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-quick; // 후보 목록 열림 시간
$easing: $ease-out;
$color: $subject-mint; // 밝고 상큼한 포인트 색 (어두운 무대 대비)
// ── 데모 무대 ──────────────────────────────────────────
@include stage($stage-ink);
.addr {
box-sizing: border-box;
width: min(300px, 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;
}
&__field {
position: relative;
}
&__input {
box-sizing: border-box;
width: 100%;
border: 2px solid transparent;
border-radius: $r-control;
padding: $sp-3 $sp-4;
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; }
}
&__listbox {
position: absolute;
top: calc(100% + #{$sp-1});
left: 0;
width: 100%;
box-sizing: border-box;
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(.92) 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 {
padding: $sp-2 $sp-3;
border-radius: $r-xs;
font-size: 13px;
font-weight: 500;
font-family: inherit;
color: $stage-ink;
cursor: pointer;
&--active { background: rgba($color, .28); }
}
&__hint {
margin: $sp-2 0 0;
font-size: 11px;
line-height: 1.4;
font-family: inherit;
color: rgba($stage-ink, .72);
}
}
@media (prefers-reduced-motion: reduce) {
.addr__listbox { transition: none !important; }
.addr__input { transition: none; }
}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);
}
.addr {
box-sizing: border-box;
width: min(300px, 88vw);
background: #fff7e6;
border-radius: 16px;
padding: 20px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.addr__label {
display: block;
margin: 0 0 8px;
font-weight: 700;
font-size: 13px;
line-height: 1;
font-family: inherit;
color: #17141a;
}
.addr__field {
position: relative;
}
.addr__input {
box-sizing: border-box;
width: 100%;
border: 2px solid transparent;
border-radius: 12px;
padding: 12px 16px;
font-weight: 500;
font-size: clamp(14px, 4.2vw, 16px);
font-family: inherit;
color: #17141a;
background: #fff;
transition: border-color 220ms ease-out;
}
.addr__input:focus {
outline: none;
border-color: #4cd4a6;
}
.addr__listbox {
position: absolute;
top: calc(100% + 4px);
left: 0;
width: 100%;
box-sizing: border-box;
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.92) translateY(-4px);
pointer-events: none;
}
.addr__listbox.is-open {
opacity: 1;
transform: scaleY(1) translateY(0);
pointer-events: auto;
transition: opacity 220ms ease-out, transform 220ms ease-out;
}
.addr__option {
padding: 8px 12px;
border-radius: 4px;
font-size: 13px;
font-weight: 500;
font-family: inherit;
color: #17141a;
cursor: pointer;
}
.addr__option--active {
background: rgba(76, 212, 166, 0.28);
}
.addr__hint {
margin: 8px 0 0;
font-size: 11px;
line-height: 1.4;
font-family: inherit;
color: rgba(23, 20, 26, 0.72);
}
@media (prefers-reduced-motion: reduce) {
.addr__listbox {
transition: none !important;
}
.addr__input {
transition: none;
}
}(function () {
var ADDRESSES = [
'서울 강남구 테헤란로 14',
'서울 마포구 월드컵로 3',
'부산 해운대구 센텀로 99',
'대구 수성구 동대구로 7',
'인천 연수구 컨벤시아대로 5'
];
var wrap = document.getElementById('wrap');
var input = document.getElementById('addr-input');
var listbox = document.getElementById('addr-listbox');
var matches = [];
var active = -1; // -1 = 강조 없음 (입력한 글자 그대로)
var open = false;
var hideTimer = null;
function render(list) {
listbox.innerHTML = '';
list.forEach(function (text, i) {
var li = document.createElement('li');
li.className = 'addr__option';
li.id = 'addr-opt-' + i;
li.setAttribute('role', 'option');
li.setAttribute('aria-selected', 'false');
li.textContent = text;
li.addEventListener('click', function () { selectOption(i, true); });
li.addEventListener('pointerenter', function () { setActive(i); });
listbox.appendChild(li);
});
}
function setActive(i) {
active = i;
var opts = listbox.querySelectorAll('.addr__option');
opts.forEach(function (o, idx) {
var on = idx === i;
o.classList.toggle('addr__option--active', on);
o.setAttribute('aria-selected', String(on));
});
if (i > -1 && opts[i]) input.setAttribute('aria-activedescendant', opts[i].id);
else input.removeAttribute('aria-activedescendant');
}
function openList() {
if (open) return;
open = true;
clearTimeout(hideTimer);
listbox.hidden = false;
void listbox.offsetWidth;
listbox.classList.add('is-open');
input.setAttribute('aria-expanded', 'true');
}
function closeList() {
if (!open) return;
open = false;
listbox.classList.remove('is-open');
input.setAttribute('aria-expanded', 'false');
input.removeAttribute('aria-activedescendant');
hideTimer = setTimeout(function () { if (!open) listbox.hidden = true; }, 220);
}
function selectOption(i, focusInput) {
var text = matches[i];
if (text === undefined) return;
input.value = text;
closeList();
setActive(-1);
matches = [];
if (focusInput) input.focus({ preventScroll: true });
}
function filterAndRender(query) {
matches = query ? ADDRESSES.filter(function (a) { return a.indexOf(query) > -1; }) : [];
if (query && matches.length) {
render(matches);
setActive(-1);
openList();
} else {
closeList();
}
}
input.addEventListener('input', function () { filterAndRender(input.value.trim()); });
input.addEventListener('keydown', function (e) {
if (e.key === 'ArrowDown') {
e.preventDefault();
if (!open && matches.length) { openList(); setActive(0); return; }
if (open && matches.length) setActive(Math.min(active + 1, matches.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (open) { if (active <= 0) setActive(-1); else setActive(active - 1); }
} else if (e.key === 'Enter') {
if (open && active > -1) { e.preventDefault(); selectOption(active, true); }
} else if (e.key === 'Escape') {
if (open) { e.preventDefault(); closeList(); setActive(-1); }
}
});
document.addEventListener('pointerdown', function (e) {
if (open && !wrap.contains(e.target)) closeList();
});
// ── 실사용자가 손대면 데모를 영구 정지 ──
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);
}
// ── 자동 데모 루프: input 이벤트를 실제로 디스패치해 진짜 필터·선택 경로를 돈다 ──
var demoTimer = null;
var QUERIES = ['서울 강남', '부산 해운대'];
var qIdx = 0;
function dispatchInput() {
input.dispatchEvent(new Event('input', { bubbles: true }));
}
function typewrite(str, i, cb) {
if (!wrap.classList.contains('is-demo')) return;
if (i <= str.length) {
input.value = str.slice(0, i);
dispatchInput();
demoTimer = setTimeout(function () { typewrite(str, i + 1, cb); }, 110);
} else {
demoTimer = setTimeout(cb, 500);
}
}
function demoStep() {
if (!wrap.classList.contains('is-demo')) return;
var query = QUERIES[qIdx % QUERIES.length];
qIdx++;
typewrite(query, 1, function () {
if (!wrap.classList.contains('is-demo')) return;
if (matches.length) {
setActive(0);
demoTimer = setTimeout(function () {
if (!wrap.classList.contains('is-demo')) return;
selectOption(0, false);
demoTimer = setTimeout(function () {
if (!wrap.classList.contains('is-demo')) return;
input.value = '';
dispatchInput();
demoTimer = setTimeout(demoStep, 400);
}, 900);
}, 450);
} else {
demoTimer = setTimeout(demoStep, 400);
}
});
}
demoTimer = setTimeout(demoStep, 500);
})();import { useRef, useState } from "react";
import styles from "./AddressAutocompleteInput.module.scss";
const ADDRESSES = [
"서울 강남구 테헤란로 14",
"서울 마포구 월드컵로 3",
"부산 해운대구 센텀로 99",
"대구 수성구 동대구로 7",
"인천 연수구 컨벤시아대로 5",
];
export default function AddressAutocompleteInput() {
const [value, setValue] = useState("");
const [matches, setMatches] = useState<string[]>([]);
const [active, setActive] = useState(-1);
const [open, setOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const wrapRef = useRef<HTMLDivElement>(null);
const filter = (query: string) => {
const trimmed = query.trim();
const next = trimmed ? ADDRESSES.filter((a) => a.indexOf(trimmed) > -1) : [];
setMatches(next);
setActive(-1);
setOpen(Boolean(trimmed) && next.length > 0);
};
const onChange = (e: any) => {
const v = String(e.target.value);
setValue(v);
filter(v);
};
const selectOption = (i: number) => {
const text = matches[i];
if (text === undefined) return;
setValue(text);
setOpen(false);
setActive(-1);
setMatches([]);
inputRef.current?.focus({ preventScroll: true });
};
const onKeyDown = (e: any) => {
if (e.key === "ArrowDown") {
e.preventDefault();
if (!open && matches.length) { setOpen(true); setActive(0); return; }
if (open && matches.length) setActive((a) => Math.min(a + 1, matches.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
if (open) setActive((a) => (a <= 0 ? -1 : a - 1));
} else if (e.key === "Enter") {
if (open && active > -1) {
e.preventDefault();
selectOption(active);
}
} else if (e.key === "Escape") {
if (open) {
e.preventDefault();
setOpen(false);
setActive(-1);
}
}
};
const activeId = active > -1 ? `addr-opt-${active}` : undefined;
return (
<div className={styles.addr} ref={wrapRef}>
<label className={styles.label} htmlFor="addr-input">배송지 주소</label>
<div className={styles.field}>
<input
ref={inputRef}
id="addr-input"
className={styles.input}
type="text"
role="combobox"
aria-expanded={open}
aria-controls="addr-listbox"
aria-autocomplete="list"
aria-haspopup="listbox"
aria-describedby="addr-hint"
aria-activedescendant={activeId}
autoComplete="off"
placeholder="주소를 입력하세요"
value={value}
onChange={onChange}
onKeyDown={onKeyDown}
onBlur={() => setOpen(false)}
/>
{open && matches.length > 0 && (
<ul className={styles.listbox} id="addr-listbox" role="listbox" aria-label="주소 후보">
{matches.map((text, i) => (
<li
key={text}
id={`addr-opt-${i}`}
role="option"
aria-selected={i === active}
className={styles.option + (i === active ? " " + styles.optionActive : "")}
onMouseDown={(e: any) => e.preventDefault()}
onClick={() => selectOption(i)}
onPointerEnter={() => setActive(i)}
>
{text}
</li>
))}
</ul>
)}
</div>
<p className={styles.hint} id="addr-hint">화살표 키로 고르고 Enter로 채우기</p>
</div>
);
}05 · 주소 자동완성 스타일 입력창 — 측정 누적면적 15.744% · 움직인 프레임 7/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
입력창은 role="combobox"에 aria-autocomplete="list"를 달았고, 후보 줄은 미리 정해 둔 주소에서 골라 만드는 정적 목록이라 API 키가 필요 없습니다.
input.addEventListener('keydown', function (e) {
if (e.key === 'ArrowDown') {
e.preventDefault();
if (!open && matches.length) { openList(); setActive(0); return; }
if (open && matches.length) setActive(Math.min(active + 1, matches.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (open) { if (active <= 0) setActive(-1); else setActive(active - 1); }
} else if (e.key === 'Enter') {
if (open && active > -1) { e.preventDefault(); selectOption(active, true); }
} else if (e.key === 'Escape') {
if (open) { e.preventDefault(); closeList(); setActive(-1); }
}
});
06다중 선택 콤보박스
어떤 움직임인가
디자인·개발·마케팅·기획 네 줄에서 여러 개를 고르면 고른 항목마다 알약 칩이 입력칸 안에 쌓입니다.

<main class="stage">
<div class="multi is-demo" id="wrap">
<span class="multi__label" id="multi-label">관심 분야</span>
<button type="button" class="multi__trigger" id="trigger"
aria-haspopup="listbox" aria-expanded="false" aria-controls="multi-listbox"
aria-labelledby="multi-label trigger" aria-describedby="multi-hint">
<span class="multi__chips" id="chips">
<span class="multi__placeholder" id="placeholder">관심 분야 선택</span>
</span>
<span class="multi__chevron" aria-hidden="true"></span>
</button>
<ul class="multi__listbox" id="multi-listbox" role="listbox" aria-multiselectable="true" aria-label="관심 분야 목록" tabindex="-1" hidden>
<li role="option" id="multi-opt-0" class="multi__option" data-value="design" data-label="디자인" aria-selected="false">
<span class="multi__check" aria-hidden="true"></span> 디자인
</li>
<li role="option" id="multi-opt-1" class="multi__option" data-value="dev" data-label="개발" aria-selected="false">
<span class="multi__check" aria-hidden="true"></span> 개발
</li>
<li role="option" id="multi-opt-2" class="multi__option" data-value="marketing" data-label="마케팅" aria-selected="false">
<span class="multi__check" aria-hidden="true"></span> 마케팅
</li>
<li role="option" id="multi-opt-3" class="multi__option" data-value="planning" data-label="기획" aria-selected="false">
<span class="multi__check" aria-hidden="true"></span> 기획
</li>
</ul>
<p class="multi__hint" id="multi-hint">화살표로 이동 · Enter로 선택/해제 · Esc로 닫기</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-quick; // 목록 열림 · 칩 등장 시간
$easing: $ease-pop; // 칩이 톡 튀어나오는 느낌
$color: $subject-pink; // 밝고 귀여운 포인트 색
// ── 데모 무대 ──────────────────────────────────────────
@include stage($stage-yellow);
.multi {
box-sizing: border-box;
position: relative;
width: min(300px, 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;
}
&__trigger {
box-sizing: border-box;
width: 100%;
display: flex;
align-items: center;
gap: $sp-2;
border: 2px solid transparent;
border-radius: $r-control;
padding: $sp-3 $sp-4;
background: #fff;
font-family: inherit;
font-size: clamp(14px, 4vw, 16px);
color: $stage-ink;
text-align: left;
cursor: pointer;
transition: border-color $duration $easing;
&:focus-visible { outline: none; border-color: $color; }
&[aria-expanded="true"] { border-color: $color; }
}
&__chips {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: $sp-1;
flex: 1 1 auto;
min-width: 0;
min-height: 20px;
}
&__placeholder {
color: rgba($stage-ink, .72);
font-weight: 500;
}
&__chip {
display: inline-flex;
align-items: center;
background: $color;
color: #fff;
border-radius: $r-pill;
padding: $sp-1 $sp-2;
font-size: 12px;
font-weight: 700;
font-family: inherit;
animation: chip-pop $duration $easing both;
}
&__chevron {
flex: 0 0 auto;
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); }
&__listbox {
position: absolute;
top: calc(100% + #{$sp-1});
left: $sp-5;
right: $sp-5;
max-height: 200px;
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(.92) 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-2;
padding: $sp-2 $sp-3;
border-radius: $r-xs;
font-size: 13px;
font-weight: 500;
font-family: inherit;
color: $stage-ink;
cursor: pointer;
&--active { background: rgba($color, .18); }
}
&__check {
box-sizing: border-box;
flex: 0 0 auto;
position: relative;
width: 14px;
height: 14px;
border-radius: $r-xs;
border: 2px solid rgba($stage-ink, .3);
background: #fff;
transition: background $duration $easing, border-color $duration $easing;
}
&__option[aria-selected="true"] &__check {
background: $color;
border-color: $color;
}
&__option[aria-selected="true"] &__check::after {
content: "";
position: absolute;
left: 3px;
top: -1px;
width: 4px;
height: 8px;
border-right: 2px solid #fff;
border-bottom: 2px solid #fff;
transform: rotate(40deg);
}
&__hint {
margin: $sp-2 0 0;
font-size: 11px;
line-height: 1.4;
font-family: inherit;
color: rgba($stage-ink, .72);
}
}
@keyframes chip-pop {
0% { transform: scale(.5); opacity: 0; }
70% { transform: scale(1.06); opacity: 1; }
100% { transform: scale(1); opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.multi__listbox { transition: none !important; }
.multi__trigger, .multi__chevron, .multi__check { transition: none; }
.multi__chip { animation: none; }
}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);
}
.multi {
box-sizing: border-box;
position: relative;
width: min(300px, 88vw);
background: #fff7e6;
border-radius: 16px;
padding: 20px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.multi__label {
display: block;
margin: 0 0 8px;
font-weight: 700;
font-size: 13px;
line-height: 1;
font-family: inherit;
color: #17141a;
}
.multi__trigger {
box-sizing: border-box;
width: 100%;
display: flex;
align-items: center;
gap: 8px;
border: 2px solid transparent;
border-radius: 12px;
padding: 12px 16px;
background: #fff;
font-family: inherit;
font-size: clamp(14px, 4vw, 16px);
color: #17141a;
text-align: left;
cursor: pointer;
transition: border-color 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.multi__trigger:focus-visible {
outline: none;
border-color: #ff9ebb;
}
.multi__trigger[aria-expanded=true] {
border-color: #ff9ebb;
}
.multi__chips {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
flex: 1 1 auto;
min-width: 0;
min-height: 20px;
}
.multi__placeholder {
color: rgba(23, 20, 26, 0.72);
font-weight: 500;
}
.multi__chip {
display: inline-flex;
align-items: center;
background: #ff9ebb;
color: #fff;
border-radius: 999px;
padding: 4px 8px;
font-size: 12px;
font-weight: 700;
font-family: inherit;
animation: chip-pop 220ms cubic-bezier(0.2, 1.4, 0.4, 1) both;
}
.multi__chevron {
flex: 0 0 auto;
width: 0;
height: 0;
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-top: 5px solid currentColor;
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.multi__trigger[aria-expanded=true] .multi__chevron {
transform: rotate(180deg);
}
.multi__listbox {
position: absolute;
top: calc(100% + 4px);
left: 20px;
right: 20px;
max-height: 200px;
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.92) translateY(-4px);
pointer-events: none;
}
.multi__listbox.is-open {
opacity: 1;
transform: scaleY(1) translateY(0);
pointer-events: auto;
transition: opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1), transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.multi__option {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 4px;
font-size: 13px;
font-weight: 500;
font-family: inherit;
color: #17141a;
cursor: pointer;
}
.multi__option--active {
background: rgba(255, 158, 187, 0.18);
}
.multi__check {
box-sizing: border-box;
flex: 0 0 auto;
position: relative;
width: 14px;
height: 14px;
border-radius: 4px;
border: 2px solid rgba(23, 20, 26, 0.3);
background: #fff;
transition: background 220ms cubic-bezier(0.2, 1.4, 0.4, 1), border-color 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.multi__option[aria-selected=true] .multi__check {
background: #ff9ebb;
border-color: #ff9ebb;
}
.multi__option[aria-selected=true] .multi__check::after {
content: "";
position: absolute;
left: 3px;
top: -1px;
width: 4px;
height: 8px;
border-right: 2px solid #fff;
border-bottom: 2px solid #fff;
transform: rotate(40deg);
}
.multi__hint {
margin: 8px 0 0;
font-size: 11px;
line-height: 1.4;
font-family: inherit;
color: rgba(23, 20, 26, 0.72);
}
@keyframes chip-pop {
0% {
transform: scale(0.5);
opacity: 0;
}
70% {
transform: scale(1.06);
opacity: 1;
}
100% {
transform: scale(1);
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.multi__listbox {
transition: none !important;
}
.multi__trigger, .multi__chevron, .multi__check {
transition: none;
}
.multi__chip {
animation: none;
}
}(function () {
var wrap = document.getElementById('wrap');
var trigger = document.getElementById('trigger');
var listbox = document.getElementById('multi-listbox');
var chips = document.getElementById('chips');
var placeholder = document.getElementById('placeholder');
var options = Array.prototype.slice.call(listbox.querySelectorAll('.multi__option'));
var selected = []; // data-value 배열, 옵션 순서 유지
var active = 0;
var open = false;
var hideTimer = null;
function highlight(i) {
active = i;
options.forEach(function (o, idx) { o.classList.toggle('multi__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;
listbox.classList.add('is-open');
trigger.setAttribute('aria-expanded', 'true');
highlight(active);
}
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('multi__option--active'); });
hideTimer = setTimeout(function () { if (!open) listbox.hidden = true; }, 220);
if (returnFocus) trigger.focus({ preventScroll: true });
}
function renderChips() {
chips.innerHTML = '';
if (!selected.length) {
chips.appendChild(placeholder);
return;
}
options.forEach(function (o) {
if (selected.indexOf(o.dataset.value) === -1) return;
var chip = document.createElement('span');
chip.className = 'multi__chip';
chip.textContent = o.dataset.label;
chips.appendChild(chip);
});
}
function toggleOption(i) {
var o = options[i];
var isSel = o.getAttribute('aria-selected') === 'true';
o.setAttribute('aria-selected', String(!isSel));
if (isSel) selected = selected.filter(function (v) { return v !== o.dataset.value; });
else selected.push(o.dataset.value);
renderChips();
}
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.key === ' ') { e.preventDefault(); toggleOption(active); }
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 () { highlight(idx); toggleOption(idx); });
o.addEventListener('pointerenter', function () { highlight(idx); });
});
document.addEventListener('pointerdown', function (e) {
if (open && !wrap.contains(e.target)) closeListbox(false);
});
renderChips();
// ── 실사용자가 손대면 데모를 영구 정지 ──
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·toggleOption·closeListbox 경로를 그대로 돈다 ──
var demoTimer = null;
function demoLoop() {
var steps = [
[function () { openListbox(); }, 500],
[function () { highlight(0); toggleOption(0); }, 550],
[function () { highlight(1); toggleOption(1); }, 900],
[function () { highlight(0); toggleOption(0); }, 300],
[function () { highlight(1); toggleOption(1); }, 300],
[function () { closeListbox(false); }, 500]
];
var i = 0;
function next() {
if (!wrap.classList.contains('is-demo')) return;
if (i >= steps.length) { demoTimer = setTimeout(demoLoop, 300); return; }
var s = steps[i++];
s[0]();
demoTimer = setTimeout(next, s[1]);
}
next();
}
demoTimer = setTimeout(demoLoop, 500);
})();import { useEffect, useRef, useState } from "react";
import styles from "./MultiSelectCombobox.module.scss";
type Option = { value: string; label: string };
const OPTIONS: Option[] = [
{ value: "design", label: "디자인" },
{ value: "dev", label: "개발" },
{ value: "marketing", label: "마케팅" },
{ value: "planning", label: "기획" },
];
export default function MultiSelectCombobox() {
const [open, setOpen] = useState(false);
const [active, setActive] = useState(0);
const [selected, setSelected] = useState<string[]>([]);
const wrapRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const openList = () => setOpen(true);
const closeList = (returnFocus: boolean) => {
setOpen(false);
if (returnFocus) triggerRef.current?.focus({ preventScroll: true });
};
const toggleOption = (i: number) => {
const value = OPTIONS[i].value;
setSelected((prev) =>
prev.indexOf(value) > -1 ? prev.filter((v) => v !== value) : [...prev, value]
);
};
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) % OPTIONS.length);
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActive((a) => (a - 1 + OPTIONS.length) % OPTIONS.length);
} else if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
toggleOption(active);
} 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]);
const chips = OPTIONS.filter((o) => selected.indexOf(o.value) > -1);
return (
<div className={styles.multi} ref={wrapRef}>
<span className={styles.label} id="multi-label">관심 분야</span>
<button
ref={triggerRef}
type="button"
className={styles.trigger}
aria-haspopup="listbox"
aria-expanded={open}
aria-controls="multi-listbox"
aria-labelledby="multi-label multi-trigger"
aria-describedby="multi-hint"
id="multi-trigger"
aria-activedescendant={open ? `multi-opt-${active}` : undefined}
onClick={() => (open ? closeList(true) : openList())}
onKeyDown={onTriggerKeyDown}
>
<span className={styles.chips}>
{chips.length === 0 ? (
<span className={styles.placeholder}>관심 분야 선택</span>
) : (
chips.map((c) => (
<span key={c.value} className={styles.chip}>{c.label}</span>
))
)}
</span>
<span className={styles.chevron + (open ? " " + styles.chevronOpen : "")} aria-hidden="true" />
</button>
{open && (
<ul className={styles.listbox} id="multi-listbox" role="listbox" aria-multiselectable="true" aria-label="관심 분야 목록">
{OPTIONS.map((o, i) => {
const isSelected = selected.indexOf(o.value) > -1;
return (
<li
key={o.value}
id={`multi-opt-${i}`}
role="option"
aria-selected={isSelected}
className={styles.option + (i === active ? " " + styles.optionActive : "")}
onClick={() => {
setActive(i);
toggleOption(i);
}}
onPointerEnter={() => setActive(i)}
>
<span className={styles.check + (isSelected ? " " + styles.checkOn : "")} aria-hidden="true" />
{o.label}
</li>
);
})}
</ul>
)}
<p className={styles.hint} id="multi-hint">화살표로 이동 · Enter로 선택/해제 · Esc로 닫기</p>
</div>
);
}06 · 다중 선택 콤보박스 — 측정 누적면적 18.629% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
목록에 aria-multiselectable="true"가 붙어 있어 둘째를 골라도 첫째가 풀리지 않고, 칩은 chip-pop 키프레임으로 살짝 지나쳤다가 제 크기에 안착합니다.
@keyframes chip-pop {
0% { transform: scale(.5); opacity: 0; }
70% { transform: scale(1.06); opacity: 1; }
100% { transform: scale(1); opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.multi__listbox { transition: none !important; }
.multi__trigger, .multi__chevron, .multi__check { transition: none; }
.multi__chip { animation: none; }
}
07멘션·해시태그 강조 입력창
무엇이 보이나
@이름과 #태그를 치는 순간 그 단어에만 색이 칠해집니다.

<main class="stage">
<div class="mh is-demo" id="wrap">
<label class="mh__label" for="mh-input">메시지</label>
<div class="mh__field">
<div class="mh__backdrop" id="backdrop" aria-hidden="true"></div>
<textarea class="mh__input" id="mh-input" rows="3" aria-describedby="mh-hint"></textarea>
</div>
<p class="mh__hint" id="mh-hint">@이름 #태그를 입력해 보세요</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-quick; // 포커스 테두리 전환 시간
$easing: $ease-out;
$color: $subject-sky; // 포커스 테두리 · 강조 텍스트 색
// ── 데모 무대 ──────────────────────────────────────────
@include stage($stage-orange);
.mh {
box-sizing: border-box;
width: min(280px, 86vw);
background: $subject-cream;
border-radius: $r-card;
padding: $sp-5 $sp-5 $sp-4;
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;
}
&__field {
position: relative;
width: 100%;
}
&__backdrop,
&__input {
box-sizing: border-box;
width: 100%;
min-height: 96px;
max-height: 160px;
padding: $sp-3 $sp-4;
border: 2px solid transparent;
border-radius: $r-control;
font-weight: 500;
font-size: clamp(13px, 3.8vw, 15px);
line-height: 1.5;
font-family: inherit;
white-space: pre-wrap;
word-wrap: break-word;
overflow-wrap: break-word;
}
&__backdrop {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
color: $stage-ink;
background: #fff;
mark {
background: none;
color: $color;
font-weight: 700;
font-family: inherit;
}
}
&__input {
position: relative;
display: block;
resize: none;
overflow-y: auto;
color: transparent;
caret-color: $stage-ink;
background: transparent;
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);
}
}
// 320px 세로(무대 174px)에 맞춘다 — 입력칸을 96px 로 두면 카드가 2px 넘친다 (BD9 실측)
@media (max-width: 340px) {
.mh__backdrop,
.mh__input { min-height: 88px; }
}
@media (prefers-reduced-motion: reduce) {
.mh__input { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.mh {
box-sizing: border-box;
width: min(280px, 86vw);
background: #fff7e6;
border-radius: 16px;
padding: 20px 20px 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.mh__label {
display: block;
margin: 0 0 8px;
font-weight: 700;
font-size: 13px;
line-height: 1;
font-family: inherit;
color: #17141a;
}
.mh__field {
position: relative;
width: 100%;
}
.mh__backdrop, .mh__input {
box-sizing: border-box;
width: 100%;
min-height: 96px;
max-height: 160px;
padding: 12px 16px;
border: 2px solid transparent;
border-radius: 12px;
font-weight: 500;
font-size: clamp(13px, 3.8vw, 15px);
line-height: 1.5;
font-family: inherit;
white-space: pre-wrap;
word-wrap: break-word;
overflow-wrap: break-word;
}
.mh__backdrop {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
color: #17141a;
background: #fff;
}
.mh__backdrop mark {
background: none;
color: #6ec8ff;
font-weight: 700;
font-family: inherit;
}
.mh__input {
position: relative;
display: block;
resize: none;
overflow-y: auto;
color: transparent;
caret-color: #17141a;
background: transparent;
transition: border-color 220ms ease-out;
}
.mh__input:focus {
outline: none;
border-color: #6ec8ff;
}
.mh__hint {
margin: 8px 0 0;
font-size: 11px;
line-height: 1.4;
font-family: inherit;
color: rgba(23, 20, 26, 0.72);
}
@media (max-width: 340px) {
.mh__backdrop,
.mh__input {
min-height: 88px;
}
}
@media (prefers-reduced-motion: reduce) {
.mh__input {
transition: none;
}
}(function () {
var wrap = document.getElementById('wrap');
var input = document.getElementById('mh-input');
var backdrop = document.getElementById('backdrop');
function escapeHtml(s) {
return s
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function highlight(s) {
return s.replace(/([@#])([\w가-힣]+)/g, function (m) {
return '<mark>' + m + '</mark>';
});
}
function rebuild() {
var v = input.value;
var html = highlight(escapeHtml(v));
backdrop.innerHTML = html + (v.slice(-1) === '\n' ? ' ' : '');
}
input.addEventListener('input', rebuild);
input.addEventListener('scroll', function () {
backdrop.scrollTop = input.scrollTop;
});
function stopDemo() {
if (!wrap.classList.contains('is-demo')) return;
wrap.classList.remove('is-demo');
clearInterval(timer);
}
input.addEventListener('pointerdown', stopDemo);
input.addEventListener('keydown', stopDemo);
var full = '@디자인팀 새 #버튼 스타일 어때요';
var i = 0, paused = false;
var timer = setInterval(function () {
if (paused) return;
if (i > full.length) {
paused = true;
setTimeout(function () { input.value = ''; rebuild(); i = 0; paused = false; }, 900);
return;
}
input.value = full.slice(0, i);
rebuild();
i++;
}, 110);
})();import { useRef, useState, type ChangeEvent } from "react";
import styles from "./MentionHighlightInput.module.scss";
export type MentionHighlightInputProps = {
label?: string;
};
function escapeHtml(s: string): string {
return s
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
function highlight(s: string): string {
const escaped = escapeHtml(s);
return escaped.replace(/([@#])([\w가-힣]+)/g, (m) => `<mark>${m}</mark>`);
}
export default function MentionHighlightInput({ label = "메시지" }: MentionHighlightInputProps) {
const [value, setValue] = useState("");
const backdropRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const onChange = (e: ChangeEvent<HTMLTextAreaElement>) => setValue(e.target.value);
const onScroll = () => {
if (backdropRef.current && inputRef.current) {
backdropRef.current.scrollTop = inputRef.current.scrollTop;
}
};
const html = highlight(value) + (value.slice(-1) === "\n" ? " " : "");
return (
<div className={styles.mh}>
<label className={styles.label} htmlFor="mh-input-react">
{label}
</label>
<div className={styles.field}>
<div
className={styles.backdrop}
aria-hidden="true"
ref={backdropRef}
dangerouslySetInnerHTML={{ __html: html }}
/>
<textarea
id="mh-input-react"
className={styles.input}
ref={inputRef}
rows={3}
value={value}
onChange={onChange}
onScroll={onScroll}
aria-describedby="mh-hint-react"
/>
</div>
<p className={styles.hint} id="mh-hint-react">
@이름 #태그를 입력해 보세요
</p>
</div>
);
}07 · 멘션·해시태그 강조 입력창 — 측정 누적면적 1.192% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
contenteditable을 쓰지 않고, 글자를 숨긴 textarea와 그 아래 깔린 backdrop 두 겹을 같은 폭·같은 줄바꿈 규칙으로 겹쳐서 커서가 제자리에 살아 있게 했습니다.
&__backdrop,
&__input {
box-sizing: border-box;
width: 100%;
min-height: 96px;
max-height: 160px;
padding: $sp-3 $sp-4;
border: 2px solid transparent;
border-radius: $r-control;
font-weight: 500;
font-size: clamp(13px, 3.8vw, 15px);
line-height: 1.5;
font-family: inherit;
white-space: pre-wrap;
word-wrap: break-word;
overflow-wrap: break-word;
}
08글자수 카운터 입력창
화면에서 일어나는 일
오른쪽 아래 숫자가 0/80부터 올라가고, 남은 글자가 12자 이하가 되면 숫자와 테두리가 빨갛게 바뀝니다.

<main class="stage">
<div class="cc is-demo" id="wrap">
<label class="cc__label" for="cc-input">자기소개</label>
<textarea class="cc__area" id="cc-input" maxlength="80" rows="3" placeholder="나를 한 줄로 소개해 보세요" aria-describedby="cc-count"></textarea>
<p class="cc__count" id="cc-count" aria-live="polite">0/80</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-quick; // 경고 색 전환 시간
$easing: $ease-out;
$color: $subject-blue-deep; // 평상시 카운터 · 포커스 테두리 색 (크림 면 위 하늘색 글자는 1.73:1)
// ── 데모 무대 ──────────────────────────────────────────
@include stage($stage-paper);
.cc {
box-sizing: border-box;
width: min(280px, 86vw);
background: $subject-cream;
border-radius: $r-card;
padding: $sp-5 $sp-5 $sp-4;
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;
}
&__area {
box-sizing: border-box;
display: block;
width: 100%;
min-height: 96px;
max-height: 160px;
resize: none;
overflow-y: auto;
border: 2px solid transparent;
border-radius: $r-control;
padding: $sp-3 $sp-4;
font-weight: 500;
font-size: clamp(13px, 3.8vw, 15px);
line-height: 1.5;
font-family: inherit;
color: $stage-ink;
background: #fff;
transition: border-color $duration $easing;
&:focus { outline: none; border-color: $color; }
&.is-warn { border-color: $error; }
}
&__count {
margin: $sp-2 0 0;
text-align: right;
font-size: 12px;
font-weight: 600;
font-family: inherit;
color: $color;
transition: color $duration $easing;
&.is-warn { color: $error; }
}
}
// 320px 세로(무대 174px)에 맞춘다 — 입력칸을 96px 로 두면 카드가 3px 넘친다 (BD9 실측)
@media (max-width: 340px) {
.cc__area { min-height: 88px; }
}
@media (prefers-reduced-motion: reduce) {
.cc__area, .cc__count { 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);
}
.cc {
box-sizing: border-box;
width: min(280px, 86vw);
background: #fff7e6;
border-radius: 16px;
padding: 20px 20px 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.cc__label {
display: block;
margin: 0 0 8px;
font-weight: 700;
font-size: 13px;
line-height: 1;
font-family: inherit;
color: #17141a;
}
.cc__area {
box-sizing: border-box;
display: block;
width: 100%;
min-height: 96px;
max-height: 160px;
resize: none;
overflow-y: auto;
border: 2px solid transparent;
border-radius: 12px;
padding: 12px 16px;
font-weight: 500;
font-size: clamp(13px, 3.8vw, 15px);
line-height: 1.5;
font-family: inherit;
color: #17141a;
background: #fff;
transition: border-color 220ms ease-out;
}
.cc__area:focus {
outline: none;
border-color: #0a4fe8;
}
.cc__area.is-warn {
border-color: #c2361a;
}
.cc__count {
margin: 8px 0 0;
text-align: right;
font-size: 12px;
font-weight: 600;
font-family: inherit;
color: #0a4fe8;
transition: color 220ms ease-out;
}
.cc__count.is-warn {
color: #c2361a;
}
@media (max-width: 340px) {
.cc__area {
min-height: 88px;
}
}
@media (prefers-reduced-motion: reduce) {
.cc__area, .cc__count {
transition: none;
}
}(function () {
var wrap = document.getElementById('wrap');
var area = document.getElementById('cc-input');
var count = document.getElementById('cc-count');
var MAX = 80;
var WARN = 12;
function update() {
var len = area.value.length;
var remaining = MAX - len;
count.textContent = len + '/' + MAX;
if (remaining <= WARN) {
count.classList.add('is-warn');
area.classList.add('is-warn');
} else {
count.classList.remove('is-warn');
area.classList.remove('is-warn');
}
}
area.addEventListener('input', update);
function stopDemo() {
if (!wrap.classList.contains('is-demo')) return;
wrap.classList.remove('is-demo');
clearInterval(timer);
}
area.addEventListener('pointerdown', stopDemo);
area.addEventListener('keydown', stopDemo);
var full = '안녕하세요! 매일 조금씩 성장하는 걸 좋아하는 프런트엔드 개발자입니다. 커피와 고양이를 좋아하고, 여행도 즐깁니다. 잘 부탁드립니다!';
var i = 0, paused = false;
var timer = setInterval(function () {
if (paused) return;
if (i > full.length) {
paused = true;
setTimeout(function () { area.value = ''; update(); i = 0; paused = false; }, 900);
return;
}
area.value = full.slice(0, i);
update();
i++;
}, 90);
update();
})();import { useState, type ChangeEvent } from "react";
import styles from "./CharacterCounterInput.module.scss";
export type CharacterCounterInputProps = {
label?: string;
max?: number;
};
const WARN_THRESHOLD = 12;
export default function CharacterCounterInput({ label = "자기소개", max = 80 }: CharacterCounterInputProps) {
const [value, setValue] = useState("");
const remaining = max - value.length;
const isWarn = remaining <= WARN_THRESHOLD;
const onChange = (e: ChangeEvent<HTMLTextAreaElement>) => setValue(e.target.value);
return (
<div className={styles.cc}>
<label className={styles.label} htmlFor="cc-input-react">
{label}
</label>
<textarea
id="cc-input-react"
className={isWarn ? `${styles.area} ${styles.warn}` : styles.area}
maxLength={max}
rows={3}
placeholder="나를 한 줄로 소개해 보세요"
value={value}
onChange={onChange}
aria-describedby="cc-count-react"
/>
<p
className={isWarn ? `${styles.count} ${styles.warn}` : styles.count}
id="cc-count-react"
aria-live="polite"
>
{value.length}/{max}
</p>
</div>
);
}08 · 글자수 카운터 입력창 — 측정 누적면적 2.316% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
한도는 HTML maxlength="80"이 지키고, 숫자는 aria-live="polite"로 읽어 주기 때문에 화면을 보지 않고도 한도를 알 수 있습니다.
function update() {
var len = area.value.length;
var remaining = MAX - len;
count.textContent = len + '/' + MAX;
if (remaining <= WARN) {
count.classList.add('is-warn');
area.classList.add('is-warn');
} else {
count.classList.remove('is-warn');
area.classList.remove('is-warn');
}
}
area.addEventListener('input', update);
09지우기(X) 버튼 입력창
어떤 움직임인가
한 글자라도 치면 오른쪽에 동그란 X 버튼이 나타나고, 누르면 칸이 비워지면서 초점이 그 칸에 그대로 남습니다.

<main class="stage">
<div class="cb is-demo" id="wrap">
<label class="cb__label" for="cb-input">검색</label>
<div class="cb__field">
<input class="cb__input" id="cb-input" type="text" placeholder="검색어를 입력하세요" autocomplete="off">
<button type="button" class="cb__clear" id="cb-clear" aria-label="지우기">
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true" focusable="false">
<line x1="3" y1="3" x2="13" y2="13" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<line x1="13" y1="3" x2="3" y2="13" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-instant; // X 버튼 팝인 시간
$easing: $ease-pop;
$color: $subject-blue; // 포커스 테두리 색
// ── 데모 무대 ──────────────────────────────────────────
@include stage($stage-ink);
.cb {
box-sizing: border-box;
width: min(272px, 84vw);
background: $subject-cream;
border-radius: $r-card;
padding: $sp-4;
box-shadow: $shadow-raised;
&__label {
display: block;
margin: 0 0 $sp-1;
font-weight: 700;
font-size: 12px;
line-height: 1;
font-family: inherit;
color: $stage-ink;
}
&__field {
position: relative;
width: 100%;
}
&__input {
box-sizing: border-box;
width: 100%;
border: 2px solid transparent;
border-radius: $r-control;
padding: $sp-3 $sp-10 $sp-3 $sp-4;
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; }
}
&__clear {
position: absolute;
top: 50%;
right: $sp-2;
transform: translateY(-50%) scale(.5);
width: 24px;
height: 24px;
display: grid;
place-items: center;
border: 0;
border-radius: 50%;
background: rgba(23, 20, 26, .08);
color: $stage-ink;
cursor: pointer;
opacity: 0;
pointer-events: none;
transition: opacity $duration $easing, transform $duration $easing, background $duration $easing;
&:hover, &:focus-visible { background: rgba(23, 20, 26, .16); }
}
}
.cb.has-value .cb__clear {
opacity: 1;
pointer-events: auto;
transform: translateY(-50%) scale(1);
}
@media (prefers-reduced-motion: reduce) {
.cb__input, .cb__clear { transition: none; }
}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);
}
.cb {
box-sizing: border-box;
width: min(272px, 84vw);
background: #fff7e6;
border-radius: 16px;
padding: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.cb__label {
display: block;
margin: 0 0 4px;
font-weight: 700;
font-size: 12px;
line-height: 1;
font-family: inherit;
color: #17141a;
}
.cb__field {
position: relative;
width: 100%;
}
.cb__input {
box-sizing: border-box;
width: 100%;
border: 2px solid transparent;
border-radius: 12px;
padding: 12px 40px 12px 16px;
font-weight: 500;
font-size: clamp(14px, 4.2vw, 16px);
font-family: inherit;
color: #17141a;
background: #fff;
transition: border-color 140ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.cb__input:focus {
outline: none;
border-color: #2f6df6;
}
.cb__clear {
position: absolute;
top: 50%;
right: 8px;
transform: translateY(-50%) scale(0.5);
width: 24px;
height: 24px;
display: grid;
place-items: center;
border: 0;
border-radius: 50%;
background: rgba(23, 20, 26, 0.08);
color: #17141a;
cursor: pointer;
opacity: 0;
pointer-events: none;
transition: opacity 140ms cubic-bezier(0.2, 1.4, 0.4, 1), transform 140ms cubic-bezier(0.2, 1.4, 0.4, 1), background 140ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.cb__clear:hover, .cb__clear:focus-visible {
background: rgba(23, 20, 26, 0.16);
}
.cb.has-value .cb__clear {
opacity: 1;
pointer-events: auto;
transform: translateY(-50%) scale(1);
}
@media (prefers-reduced-motion: reduce) {
.cb__input, .cb__clear {
transition: none;
}
}(function () {
var wrap = document.getElementById('wrap');
var input = document.getElementById('cb-input');
var clearBtn = document.getElementById('cb-clear');
function sync() {
if (input.value) {
wrap.classList.add('has-value');
} else {
wrap.classList.remove('has-value');
}
}
function doClear() {
input.value = '';
sync();
input.focus({ preventScroll: true });
}
input.addEventListener('input', sync);
clearBtn.addEventListener('click', function () { stopDemo(); doClear(); });
input.addEventListener('keydown', function (e) {
stopDemo();
if (e.key === 'Escape' && input.value) {
doClear();
}
});
input.addEventListener('pointerdown', stopDemo);
function stopDemo() {
if (!wrap.classList.contains('is-demo')) return;
wrap.classList.remove('is-demo');
clearInterval(timer);
}
var full = '여름 원피스';
var i = 0, paused = false;
var timer = setInterval(function () {
if (paused) return;
if (i > full.length) {
paused = true;
setTimeout(function () { doClear(); i = 0; paused = false; }, 900);
return;
}
input.value = full.slice(0, i);
sync();
i++;
}, 130);
})();import { useRef, useState, type ChangeEvent } from "react";
import styles from "./ClearButtonInput.module.scss";
export type ClearButtonInputProps = {
label?: string;
};
export default function ClearButtonInput({ label = "검색" }: ClearButtonInputProps) {
const [value, setValue] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const clear = () => {
setValue("");
inputRef.current?.focus({ preventScroll: true });
};
const onChange = (e: ChangeEvent<HTMLInputElement>) => setValue(e.target.value);
const onKeyDown = (e: any) => {
if (e.key === "Escape" && value) {
clear();
}
};
return (
<div className={styles.cb}>
<label className={styles.label} htmlFor="cb-input-react">
{label}
</label>
<div className={styles.field}>
<input
id="cb-input-react"
ref={inputRef}
className={styles.input}
type="text"
placeholder="검색어를 입력하세요"
autoComplete="off"
value={value}
onChange={onChange}
onKeyDown={onKeyDown}
/>
<button
type="button"
className={value ? `${styles.clear} ${styles.visible}` : styles.clear}
aria-label="지우기"
onClick={clear}
>
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true" focusable="false">
<line x1="3" y1="3" x2="13" y2="13" stroke="currentColor" strokeWidth={2} strokeLinecap="round" />
<line x1="13" y1="3" x2="3" y2="13" stroke="currentColor" strokeWidth={2} strokeLinecap="round" />
</svg>
</button>
</div>
</div>
);
}09 · 지우기(X) 버튼 입력창 — 측정 누적면적 2.114% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
버튼은 값이 없으면 opacity: 0에 pointer-events: none으로 완전히 물러나 있고, 값이 생기는 순간만 살아납니다.
&__clear {
position: absolute;
top: 50%;
right: $sp-2;
transform: translateY(-50%) scale(.5);
width: 24px;
height: 24px;
display: grid;
place-items: center;
border: 0;
border-radius: 50%;
background: rgba(23, 20, 26, .08);
color: $stage-ink;
cursor: pointer;
opacity: 0;
pointer-events: none;
transition: opacity $duration $easing, transform $duration $easing, background $duration $easing;
&:hover, &:focus-visible { background: rgba(23, 20, 26, .16); }
}
어디서 깨지는가 — 함정
세로로 깨진 것은 320px 폭에서 나타났습니다. 07과 08은 둘 다 세 줄짜리 textarea를 품고 있는데, 입력칸의 최소 높이를 96px로 두자 카드 전체가 그 시점의 무대보다 07은 2px, 08은 3px 길어져 아래 테두리가 잘렸습니다. 데모 크기 480px에서는 한참 남던 여백이 좁은 화면에서는 없는 것과 같아서, 넓은 화면에서 눈으로 확인한 것만으로는 이 결함을 볼 수 없었습니다. 고친 방법은 입력창을 흐름에서 빼는 것이 아니라, 화면 폭이 340px 이하로 내려가는 순간부터 min-height를 96px에서 88px로 낮추는 @media (max-width: 340px) 블록을 추가하는 것이었습니다. 라벨과 힌트 글자는 그대로 두고 입력칸의 세로만 8px 줄였더니 두 항목 모두 넘침 판정이 사라졌습니다. 목록이 열리는 04·05·06을 바깥길로 푼 형태는 셀렉트박스 디자인 9종이 따로 다룹니다. 이 규칙을 넣은 소스는 압축 비밀번호 ttw779pg를 입력하면 열리는 zip 안에 아홉 벌 그대로 들어 있습니다.
접근성
아홉 개 전부 prefers-reduced-motion: reduce에서 전환을 끄고 상태 값은 남깁니다. 테두리와 아이콘 색은 전환 없이 즉시 바뀌고, 떠오르던 라벨은 떠오른 자리에 그대로 서며, 06의 칩은 animation: none으로 튀지 않고 바로 나타납니다. 목록이 열리는 04·05·06은 aria-expanded로 열림을 알리고 aria-activedescendant로 초점을 옮기지 않은 채 가리키는 줄만 바꾸며, 08의 글자수는 aria-live="polite"로 읽힙니다. 초점을 되돌릴 때 focus({ preventScroll: true })를 쓰는 것은 이 데모들이 글 안에 iframe으로 들어 있어, 그냥 focus()를 부르면 읽던 사람의 화면이 아래로 끌려가기 때문입니다. 여기 쓴 입력 속성의 뜻은 MDN의 input 요소 문서에 정리돼 있습니다.
같은 자리에 쓸 만한 부품은 폼·입력 카테고리에, 입력이 틀렸을 때 흔들리는 반응은 에러 카테고리에 모아 두었습니다.
FAQ
달력이 열리는 생년월일 칸을 두고 왜 직접 만드나요?
브라우저가 주는 달력은 호환성이 좋지만 모양이 브라우저마다 다르고, 키보드로 빠르게 치는 사람에게는 열고 닫는 동안이 오히려 느립니다. 이 칸은 숫자만 받아 2026/09/11 모양으로 붙여 주는 글칸이라 달력 없이도 서식이 흐트러지지 않습니다. 달력이 필요한 화면이라면 이 칸과 달력을 함께 두는 편입니다.
로그인 폼 디자인까지 아홉 개 전부 키보드만으로 쓸 수 있나요?
네. 01·02·03·07·08은 평범한 input과 textarea라 브라우저가 기본으로 하는 일을 그대로 씁니다. 목록이 열리는 04·05·06은 화살표로 옮기고 Enter로 고르고 Escape로 닫으며, 04와 06은 양 끝에서 화살표가 감아서 돌아가고 Tab으로 나가면 목록이 닫힙니다. 09는 입력 칸에서 Escape를 누르면 값이 지워집니다. 이 아홉 칸이 여러 단계로 쪼개지면 회원가입 단계 UI 9가지의 화면 구성이 됩니다.
React 버전에서 상한이나 색을 바꿀 수 있나요?
08의 글자수 상한은 max prop으로 바꿀 수 있고 기본값은 80입니다. 경고가 시작되는 남은 글자수 12는 컴포넌트 파일 안의 상수라 값을 바꾸려면 그 줄을 고칩니다. 색과 속도는 React의 module.scss에 직접 적힌 값을 고치면 되고, 데모의 style.scss는 맨 위의 $duration·$easing·$color 세 줄만 바꾸면 됩니다.









