Register Form Design: 9 Keyboard-Friendly Inputs
Register form design lives or dies in the fields, not the submit button. These nine inputs cover everything a signup screen asks for, from the email box to the
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
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Input with icon or unit prefix/suffix
- 02 Floating label input
- 03 Date input with auto-slash
- 04 Phone input with country code select
- 05 Address autocomplete style input
- 06 Multi-select combobox
- 07 Mention and hashtag highlight input
- 08 Character counter input
- 09 Input with clear (X) button
The order follows the screen, not the usual register form design html template. The email box up top (01) says what it wants with an icon or a unit before you touch it, the name field (02) lifts its label out of the way to save space, and the birthdate (03) and phone number (04) reformat digits into the shape a person reads. Next come the fields you fill by picking from a list, the address (05) and the interests (06). The last three cover what happens while someone writes a bio: mentions and hashtags pick up color as you type (07), the character counter watches the limit (08), and the clear button empties the field in one tap when you want a do-over (09). The three that open a list (04, 05, 06) carry role="listbox" with aria-activedescendant, so the arrow keys genuinely work, and four of the nine answer Escape. The same input pillar widened to payment and notes is CSS Input Design, and judging whether what's typed is right or wrong is split out into 9 Form Validation UI Patterns.
01Input with icon or unit prefix/suffix
What this register form design ui shows
An email icon sits on the left and a kg unit on the right, so each field announces what it wants before you type.
<main class="stage">
<div class="affix is-demo" id="wrap">
<div class="affix__field">
<label class="affix__label" for="email-input">Email</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">Weight</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 · Input with icon or unit prefix/suffix — Measured area 1.326% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
On focus the 2px border, the icon, and the unit all shift to the same color, and the icon and unit carry aria-hidden="true" with pointer-events: none so they never steal the click.
&__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; }
}
}
02Floating label input
What happens on screen
In the empty field the label rests in the middle of the box; the moment the field takes focus or holds a character, that label lifts and shrinks with 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">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 = 'Jane Doe';
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 · Floating label input — Measured area 1.207% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
No JavaScript is involved — just the :focus and :not(:placeholder-shown) pseudo-classes plus the + sibling combinator, fed by a single placeholder=" " in the markup.
&__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;
}
03Date input with auto-slash
What moves
Type digits only and slashes appear after the fourth and sixth digits, so 2026/09/11 assembles itself.

<main class="stage">
<div class="dt is-demo" id="wrap">
<label class="dt__label" for="dt-input">Birthdate</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">Type digits only — slashes are added automatically</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 · Date input with auto-slash — Measured area 8.603% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
There is no calendar to open — the field is a plain text box with inputmode="numeric", which brings up the number pad on a phone, and anything past eight digits is dropped.
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);
});
04Phone input with country code select
What you see
Pick a country from the flag button and the dial code swaps while the number field rewrites itself in that country's digit grouping.

<main class="stage">
<div class="phone is-demo" id="wrap">
<span class="phone__label" id="phone-label">Phone number</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="Select country, currently South Korea">
<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="Country list" tabindex="-1" hidden>
<li role="option" id="opt-kr" class="phone__option" data-code="kr" aria-selected="true">
<span aria-hidden="true">🇰🇷</span> South Korea <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> United States <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> Japan <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">Arrow keys to pick a country · Enter to confirm</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: 'South Korea', flag: '🇰🇷', dial: '+82', groups: [3, 4, 4], sample: '1012345678' },
us: { name: 'United States', flag: '🇺🇸', dial: '+1', groups: [3, 3, 4], sample: '2025551234' },
jp: { name: 'Japan', 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', 'Select country, currently ' + 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 · Phone input with country code select — Measured area 14.364% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
South Korea's +82 groups as 3-4-4, the United States' +1 as 3-3-4, and Japan's +81 as 2-4-4, so the same ten digits read differently depending on where the number lives.
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); }
});
05Address autocomplete style input
What happens on screen
As you type, the suggestion list below filters five fixed addresses down to the matching rows, and the arrow keys plus Enter fill the field.

<main class="stage">
<div class="addr is-demo" id="wrap">
<label class="addr__label" for="addr-input">Shipping address</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="Type an address">
<ul class="addr__listbox" id="addr-listbox" role="listbox" aria-label="Address suggestions" hidden></ul>
</div>
<p class="addr__hint" id="addr-hint">Arrow keys to pick · Enter to fill in</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 Teheran-ro, Gangnam-gu, Seoul',
'3 World Cup-ro, Mapo-gu, Seoul',
'99 Centum-ro, Haeundae-gu, Busan',
'7 Dongdaegu-ro, Suseong-gu, Daegu',
'5 Convensia-daero, Yeonsu-gu, Incheon'
];
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 = ['Seoul Gangnam', 'Busan Haeundae'];
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 · Address autocomplete style input — Measured area 15.744% · moving frames 7/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
The input carries role="combobox" with aria-autocomplete="list", and the rows come from a static list, so there's no API key to set up.
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); }
}
});
06Multi-select combobox
What moves
Four rows — design, dev, marketing, planning — stack every pick as a pill-shaped chip inside the field.

<main class="stage">
<div class="multi is-demo" id="wrap">
<span class="multi__label" id="multi-label">Interests</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">Select interests</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="Interest list" tabindex="-1" hidden>
<li role="option" id="multi-opt-0" class="multi__option" data-value="design" data-label="Design" aria-selected="false">
<span class="multi__check" aria-hidden="true"></span> Design
</li>
<li role="option" id="multi-opt-1" class="multi__option" data-value="dev" data-label="Development" aria-selected="false">
<span class="multi__check" aria-hidden="true"></span> Development
</li>
<li role="option" id="multi-opt-2" class="multi__option" data-value="marketing" data-label="Marketing" aria-selected="false">
<span class="multi__check" aria-hidden="true"></span> Marketing
</li>
<li role="option" id="multi-opt-3" class="multi__option" data-value="planning" data-label="Planning" aria-selected="false">
<span class="multi__check" aria-hidden="true"></span> Planning
</li>
</ul>
<p class="multi__hint" id="multi-hint">Arrow keys to move · Enter to toggle · Esc to close</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 · Multi-select combobox — Measured area 18.629% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
The list carries aria-multiselectable="true" so a second pick never deselects the first, and each chip arrives through the chip-pop keyframes, overshooting slightly before settling at full size.
@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; }
}
07Mention and hashtag highlight input
What you see
The moment you type @name or #tag, that word alone picks up color while the rest of the sentence stays plain.

<main class="stage">
<div class="mh is-demo" id="wrap">
<label class="mh__label" for="mh-input">Message</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">Try typing @name or #tag</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 = '@designteam how\'s this new #button style';
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 · Mention and hashtag highlight input — Measured area 1.192% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
There is no contenteditable here — a textarea with invisible text sits on a backdrop layer of the same width and wrapping rules, so the caret stays where it belongs.
&__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;
}
08Character counter input
What happens on screen
The number in the corner climbs from 0/80, and once twelve characters remain, both the count and the border turn red.

<main class="stage">
<div class="cc is-demo" id="wrap">
<label class="cc__label" for="cc-input">Bio</label>
<textarea class="cc__area" id="cc-input" maxlength="80" rows="3" placeholder="Introduce yourself in one line" 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 = 'Hi! I\'m a front-end developer who loves growing a little every day. I love coffee, cats, and traveling. Nice to meet you!';
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 · Character counter input — Measured area 2.316% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
The limit itself lives in the HTML maxlength="80", and the count carries aria-live="polite", so someone who isn't watching the screen still hears the limit approach.
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);
09Input with clear (X) button
What moves
Type one character and a round X button surfaces on the right; click it and the field empties while focus stays exactly where it was.

<main class="stage">
<div class="cb is-demo" id="wrap">
<label class="cb__label" for="cb-input">Search</label>
<div class="cb__field">
<input class="cb__input" id="cb-input" type="text" placeholder="Type to search" autocomplete="off">
<button type="button" class="cb__clear" id="cb-clear" aria-label="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" 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 = 'summer dress';
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 · Input with clear (X) button — Measured area 2.114% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
With no value the button sits at opacity: 0 with pointer-events: none, fully out of the way, and it only reappears the moment there's something to clear.
&__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); }
}
Where it breaks — the trap
The break showed up at 320px wide, and it was vertical. Items 07 and 08 both hold a three-row textarea, and with the field's minimum height at 96px the whole card grew 2px taller than the stage in 07 and 3px taller in 08, clipping the bottom border. At the 480px demo size the slack had hidden the problem, so eyes on a wide screen alone could never have caught it. The fix was not to pull the textarea out of the flow but to add an @media (max-width: 340px) block that lowers min-height from 96px to 88px once the viewport narrows past that point. The label and the hint stayed untouched, and shaving those 8px off the field alone cleared the overflow in both items. Solving 04, 05, and 06 the dropdown way instead is what 9 Select Dropdown Design Patterns covers. The sources carrying this rule sit inside the zip, which opens with the archive password ttw779pg.
Accessibility
Under prefers-reduced-motion: reduce, all nine drop their transitions and keep every state value. Border and icon colors switch instantly, the floating label stands in its lifted position, and 06's chips appear without the chip-pop bounce. The three list-openers (04, 05, 06) announce openness through aria-expanded and move a virtual cursor with aria-activedescendant instead of moving focus, and 08's count is read out through aria-live="polite". Focus returns through focus({ preventScroll: true }) because these demos sit in iframes inside the article, and a bare focus() would drag the reader's page down toward them. The meaning of the input attributes used here is documented in the MDN input element reference.
More parts in the same vein live in the forms category, and the parts that shake on invalid input live in the error category.
FAQ
Why build a birthdate field instead of letting the browser open a calendar?
The native calendar is dependable, but it looks different in every browser, and for someone who types fast, opening and dismissing it is slower than typing. This field takes digits only and assembles them into the 2026/09/11 shape, so the format never drifts — no calendar required. On screens that do want a calendar, the typed field and a calendar picker can sit side by side.
Does this signup form design work without a mouse?
Yes. Items 01, 02, 03, 07, and 08 are ordinary inputs and textareas, so whatever the browser does for free is what they do. The list-openers 04, 05, and 06 move with the arrow keys, commit on Enter, and close on Escape; 04 and 06 wrap around at either end and close the list when you Tab away. In 09, pressing Escape in the field clears the value. Break these nine fields across several screens and you're in 9 Multi Step Form UI Patterns territory.
Can I change the limit or the colors in the React version?
The character limit in 08 is a max prop that defaults to 80. The warn threshold of twelve remaining characters is a constant inside the module, so changing it means editing that line. Colors and timing are inlined in each React module, so edit them where they appear; in the vanilla sources the same three knobs sit at the top of each style.scss — $duration, $easing, and $color.









