会員登録 フォーム デザイン 入力欄9選 — キーボード操作対応
会員登録 フォーム デザインの良し悪しは送信ボタンではなく入力欄で決まります。ここに並べた9つは、登録画面が実際に求める入力欄で、メールアドレス欄からクリアボタンまで、コードを貼ればそのまま動きます。
02 floating-label-input
03 date-input-auto-slash
04 phone-input-country-select
05 address-autocomplete-style
06 multi-select-combobox
07 mention-hashtag-highlight-input
08 character-counter-input
09 input-clear-button
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 アイコン・単位付き入力欄
- 02 フローティングラベル入力欄
- 03 日付の自動スラッシュ入力欄
- 04 国番号選択付き電話番号入力欄
- 05 住所オートコンプリート風入力欄
- 06 複数選択コンボボックス
- 07 メンション・ハッシュタグ強調入力欄
- 08 文字数カウンター入力欄
- 09 クリア(×)ボタン付き入力欄
並び順は画面に出てくる順です。一番上のメール欄(01)はアイコンと単位で何を書く欄か先に教え、名前欄(02)はラベルが上に浮いて場所を節約し、生年月日(03)と電話番号(04)は数字を人が読む形に組み直します。次は 住所 入力欄(05)や興味分野(06)のように一覧から選んで埋める欄です。最後の3つは自己紹介を書いている間に起きることです。メンションとハッシュタグ(07)は書きながら色が付き、文字数カウンター(08)は上限を見張り、クリアボタン(09)は書き直したくなったら一度に空にします。一覧が開く3つ(04·05·06)には role="listbox" と aria-activedescendant が付いて矢印キーが実際に効き、Escape は04·05·06·09の4つが受け付けます。同じinput柱で決済やメモまで幅を広げたのが入力欄 デザイン 9選で、書かれた値の正誤だけを切り出したのがフォーム バリデーション UI 9選です。
01アイコン・単位付き入力欄
css 入力フォーム デザインとして何が見えるか
左にメールアイコン、右に kg の単位が絶対配置で付いていて、触る前に何を書く欄か分かります。フォーカスが来ると 2px の枠とアイコン・単位が同じ色に変わります。
<main class="stage">
<div class="affix is-demo" id="wrap">
<div class="affix__field">
<label class="affix__label" for="email-input">メールアドレス</label>
<div class="affix__control">
<svg class="affix__icon" aria-hidden="true" viewBox="0 0 20 20" width="16" height="16" fill="none">
<rect x="2" y="4" width="16" height="12" rx="2" stroke="currentColor" stroke-width="1.6"/>
<path d="M2.5 5L10 11L17.5 5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<input class="affix__input affix__input--prefix" id="email-input" type="text" placeholder="[email protected]">
</div>
</div>
<div class="affix__field">
<label class="affix__label" for="weight-input">体重</label>
<div class="affix__control">
<input class="affix__input affix__input--suffix" id="weight-input" type="text" placeholder="0">
<span class="affix__unit" aria-hidden="true">kg</span>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-quick; // 테두리 색 전환 시간
$easing: $ease-out; // 포커스 이징
$color: $subject-blue; // 포커스 강조 색
// ── 데모 무대 ──────────────────────────────────────────
@include stage($stage-ink);
.affix {
box-sizing: border-box;
width: min(280px, 86vw);
background: $subject-cream;
border-radius: $r-card;
padding: $sp-4;
box-shadow: $shadow-raised;
display: flex;
flex-direction: column;
gap: $sp-4;
&__field {
display: flex;
flex-direction: column;
gap: $sp-1;
}
&__label {
display: block;
font-weight: 700;
font-size: 12px;
line-height: 1;
font-family: inherit;
color: $stage-ink;
}
&__control {
position: relative;
display: flex;
align-items: center;
}
&__icon {
position: absolute;
left: $sp-3;
color: rgba(23, 20, 26, .45);
pointer-events: none;
transition: color $duration $easing;
}
&__unit {
position: absolute;
right: $sp-3;
font-size: 13px;
font-weight: 600;
font-family: inherit;
color: rgba(23, 20, 26, .45);
pointer-events: none;
transition: color $duration $easing;
}
&__input {
box-sizing: border-box;
width: 100%;
border: 2px solid transparent;
border-radius: $r-control;
padding: $sp-3 $sp-4;
font-weight: 500;
font-size: clamp(14px, 4.2vw, 16px);
font-family: inherit;
color: $stage-ink;
background: #fff;
transition: border-color $duration $easing;
&--prefix { padding-left: $sp-10; }
&--suffix { padding-right: $sp-8; }
&:focus {
outline: none;
border-color: $color;
& ~ .affix__unit { color: $color; }
}
}
&__control:focus-within &__icon { color: $color; }
}
@media (prefers-reduced-motion: reduce) {
.affix__input,
.affix__icon,
.affix__unit {
transition: none;
}
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.affix {
box-sizing: border-box;
width: min(280px, 86vw);
background: #fff7e6;
border-radius: 16px;
padding: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
display: flex;
flex-direction: column;
gap: 16px;
}
.affix__field {
display: flex;
flex-direction: column;
gap: 4px;
}
.affix__label {
display: block;
font-weight: 700;
font-size: 12px;
line-height: 1;
font-family: inherit;
color: #17141a;
}
.affix__control {
position: relative;
display: flex;
align-items: center;
}
.affix__icon {
position: absolute;
left: 12px;
color: rgba(23, 20, 26, 0.45);
pointer-events: none;
transition: color 220ms ease-out;
}
.affix__unit {
position: absolute;
right: 12px;
font-size: 13px;
font-weight: 600;
font-family: inherit;
color: rgba(23, 20, 26, 0.45);
pointer-events: none;
transition: color 220ms ease-out;
}
.affix__input {
box-sizing: border-box;
width: 100%;
border: 2px solid transparent;
border-radius: 12px;
padding: 12px 16px;
font-weight: 500;
font-size: clamp(14px, 4.2vw, 16px);
font-family: inherit;
color: #17141a;
background: #fff;
transition: border-color 220ms ease-out;
}
.affix__input--prefix {
padding-left: 40px;
}
.affix__input--suffix {
padding-right: 32px;
}
.affix__input:focus {
outline: none;
border-color: #2f6df6;
}
.affix__input:focus ~ .affix__unit {
color: #2f6df6;
}
.affix__control:focus-within .affix__icon {
color: #2f6df6;
}
@media (prefers-reduced-motion: reduce) {
.affix__input,
.affix__icon,
.affix__unit {
transition: none;
}
}(function () {
var wrap = document.getElementById('wrap');
var emailInput = document.getElementById('email-input');
var weightInput = document.getElementById('weight-input');
function stopDemo() {
if (!wrap.classList.contains('is-demo')) return;
wrap.classList.remove('is-demo');
clearInterval(timer);
}
emailInput.addEventListener('pointerdown', stopDemo);
emailInput.addEventListener('keydown', stopDemo);
weightInput.addEventListener('pointerdown', stopDemo);
weightInput.addEventListener('keydown', stopDemo);
function fireInput(el) {
el.dispatchEvent(new Event('input', { bubbles: true }));
}
// 각 단계: target(어느 input) · text(칠 문자열) · hold(다 친 뒤 멈춤 ms)
var script = [
{ el: emailInput, text: '[email protected]', hold: 900 },
{ el: weightInput, text: '58', hold: 900 }
];
var stepIdx = 0, chars = '', phase = 'focus', pauseUntil = 0;
var timer = setInterval(function () {
var now = Date.now();
if (now < pauseUntil) return;
var step = script[stepIdx];
if (phase === 'focus') {
step.el.focus({ preventScroll: true });
phase = 'type';
return;
}
if (phase === 'type') {
if (chars.length < step.text.length) {
chars += step.text[chars.length];
step.el.value = chars;
fireInput(step.el);
} else {
phase = 'hold';
pauseUntil = now + step.hold;
}
return;
}
if (phase === 'hold') {
step.el.value = '';
fireInput(step.el);
step.el.blur();
chars = '';
stepIdx = (stepIdx + 1) % script.length;
phase = 'focus';
pauseUntil = now + 220;
}
}, 140);
})();import { useState } from "react";
import styles from "./IconAffixInput.module.scss";
export type IconAffixInputProps = {
emailLabel?: string;
weightLabel?: string;
};
export default function IconAffixInput({
emailLabel = "이메일",
weightLabel = "몸무게",
}: IconAffixInputProps) {
const [email, setEmail] = useState("");
const [weight, setWeight] = useState("");
return (
<div className={styles.affix}>
<div className={styles.field}>
<label className={styles.label} htmlFor="email-input">
{emailLabel}
</label>
<div className={styles.control}>
<svg
className={styles.icon}
aria-hidden="true"
viewBox="0 0 20 20"
width="16"
height="16"
fill="none"
>
<rect x="2" y="4" width="16" height="12" rx="2" stroke="currentColor" strokeWidth="1.6" />
<path
d="M2.5 5L10 11L17.5 5"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<input
className={`${styles.input} ${styles.inputPrefix}`}
id="email-input"
type="text"
placeholder="[email protected]"
value={email}
onChange={(e: any) => setEmail(e.target.value)}
/>
</div>
</div>
<div className={styles.field}>
<label className={styles.label} htmlFor="weight-input">
{weightLabel}
</label>
<div className={styles.control}>
<input
className={`${styles.input} ${styles.inputSuffix}`}
id="weight-input"
type="text"
placeholder="0"
value={weight}
onChange={(e: any) => setWeight(e.target.value)}
/>
<span className={styles.unit} aria-hidden="true">
kg
</span>
</div>
</div>
</div>
);
}01 · アイコン・単位付き入力欄 — 計測 累積面積 1.326% · 動いたフレーム 11/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
アイコンと単位には aria-hidden="true" と pointer-events: none が付いていてクリックを横取りしません。
&__input {
box-sizing: border-box;
width: 100%;
border: 2px solid transparent;
border-radius: $r-control;
padding: $sp-3 $sp-4;
font-weight: 500;
font-size: clamp(14px, 4.2vw, 16px);
font-family: inherit;
color: $stage-ink;
background: #fff;
transition: border-color $duration $easing;
&--prefix { padding-left: $sp-10; }
&--suffix { padding-right: $sp-8; }
&:focus {
outline: none;
border-color: $color;
& ~ .affix__unit { color: $color; }
}
}
02フローティングラベル入力欄
画面で起きること
空欄ではラベルが枠の真ん中にありますが、フォーカスか文字1つで translateY(-14px) scale(.78) で上に浮いて小さくなります。

<main class="stage">
<div class="fl is-demo" id="wrap">
<div class="fl__control">
<input class="fl__input" id="fl-name" type="text" placeholder=" ">
<label class="fl__label" for="fl-name">お名前</label>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-quick; // 라벨이 뜨는 시간
$easing: $ease-spring; // 스프링 이징
$color: $subject-lilac; // 포커스·라벨 강조 색
// ── 데모 무대 ──────────────────────────────────────────
@include stage($stage-yellow);
.fl {
box-sizing: border-box;
width: min(260px, 82vw);
background: #fff;
border-radius: $r-card;
padding: $sp-5;
box-shadow: $shadow-raised;
&__control {
position: relative;
}
&__input {
box-sizing: border-box;
width: 100%;
border: 2px solid rgba(23, 20, 26, .12);
border-radius: $r-control;
padding: $sp-5 $sp-4 $sp-2;
font-weight: 500;
font-size: clamp(14px, 4.2vw, 16px);
font-family: inherit;
color: $stage-ink;
background: #fff;
transition: border-color $duration $easing;
&:focus {
outline: none;
border-color: $color;
}
}
&__label {
position: absolute;
left: $sp-4;
top: 50%;
transform: translateY(-50%) scale(1);
transform-origin: left center; // 규칙8: 라벨 왼쪽 붙어 축소
font-size: 16px;
font-weight: 500;
font-family: inherit;
color: rgba(23, 20, 26, .48);
pointer-events: none;
transition: transform $duration $easing, color $duration $easing;
}
&__input:focus + &__label,
&__input:not(:placeholder-shown) + &__label {
transform: translateY(-14px) scale(.78);
color: $color;
}
}
@media (prefers-reduced-motion: reduce) {
.fl__input,
.fl__label {
transition: none;
}
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.fl {
box-sizing: border-box;
width: min(260px, 82vw);
background: #fff;
border-radius: 16px;
padding: 20px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.fl__control {
position: relative;
}
.fl__input {
box-sizing: border-box;
width: 100%;
border: 2px solid rgba(23, 20, 26, 0.12);
border-radius: 12px;
padding: 20px 16px 8px;
font-weight: 500;
font-size: clamp(14px, 4.2vw, 16px);
font-family: inherit;
color: #17141a;
background: #fff;
transition: border-color 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.fl__input:focus {
outline: none;
border-color: #b9a0ff;
}
.fl__label {
position: absolute;
left: 16px;
top: 50%;
transform: translateY(-50%) scale(1);
transform-origin: left center;
font-size: 16px;
font-weight: 500;
font-family: inherit;
color: rgba(23, 20, 26, 0.48);
pointer-events: none;
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1), color 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.fl__input:focus + .fl__label, .fl__input:not(:placeholder-shown) + .fl__label {
transform: translateY(-14px) scale(0.78);
color: #b9a0ff;
}
@media (prefers-reduced-motion: reduce) {
.fl__input,
.fl__label {
transition: none;
}
}(function () {
var wrap = document.getElementById('wrap');
var input = document.getElementById('fl-name');
function stopDemo() {
if (!wrap.classList.contains('is-demo')) return;
wrap.classList.remove('is-demo');
clearInterval(timer);
}
input.addEventListener('pointerdown', stopDemo);
input.addEventListener('keydown', stopDemo);
function fireInput() {
input.dispatchEvent(new Event('input', { bubbles: true }));
}
var word = '山田 花子';
var chars = '';
var phase = 'focus';
var pauseUntil = 0;
var timer = setInterval(function () {
var now = Date.now();
if (now < pauseUntil) return;
if (phase === 'focus') {
input.focus({ preventScroll: true });
phase = 'type';
return;
}
if (phase === 'type') {
if (chars.length < word.length) {
chars += word[chars.length];
input.value = chars;
fireInput();
} else {
phase = 'hold';
pauseUntil = now + 260;
}
return;
}
if (phase === 'hold') {
phase = 'clear';
return;
}
if (phase === 'clear') {
chars = '';
input.value = '';
fireInput();
input.blur();
phase = 'blurhold';
pauseUntil = now + 220;
return;
}
if (phase === 'blurhold') {
phase = 'focus';
}
}, 90);
})();import { useState } from "react";
import styles from "./FloatingLabelInput.module.scss";
export type FloatingLabelInputProps = {
label?: string;
};
export default function FloatingLabelInput({ label = "이름" }: FloatingLabelInputProps) {
const [value, setValue] = useState("");
return (
<div className={styles.fl}>
<div className={styles.control}>
<input
className={styles.input}
id="fl-name"
type="text"
placeholder=" "
value={value}
onChange={(e: any) => setValue(e.target.value)}
/>
<label className={styles.label} htmlFor="fl-name">
{label}
</label>
</div>
</div>
);
}02 · フローティングラベル入力欄 — 計測 累積面積 1.207% · 動いたフレーム 9/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
JS は使わず :focus と :not(:placeholder-shown) の2つのセレクタだけで作ってあり、placeholder=" " の1文字がこの仕掛けの材料です。
&__label {
position: absolute;
left: $sp-4;
top: 50%;
transform: translateY(-50%) scale(1);
transform-origin: left center;
font-size: 16px;
font-weight: 500;
font-family: inherit;
color: rgba(23, 20, 26, .48);
pointer-events: none;
transition: transform $duration $easing, color $duration $easing;
}
&__input:focus + &__label,
&__input:not(:placeholder-shown) + &__label {
transform: translateY(-14px) scale(.78);
color: $color;
}
03日付の自動スラッシュ入力欄
どんな動きか
数字だけを打つと 2026/09/11 のように4桁目と6桁目の後にスラッシュが入ります。カレンダーを開くのではなく inputmode="numeric" を付けたテキスト欄なので、スマホでは最初に数字キーパッドが出ます。

<main class="stage">
<div class="dt is-demo" id="wrap">
<label class="dt__label" for="dt-input">生年月日</label>
<input class="dt__input" id="dt-input" type="text" inputmode="numeric" placeholder="YYYY/MM/DD" aria-describedby="dt-hint">
<p class="dt__hint" id="dt-hint">数字だけ入力すればスラッシュが自動的に入ります</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-quick; // 테두리 색 전환 시간
$easing: $ease-out; // 포커스 이징
$color: $stage-orange; // 포커스 강조 색
// ── 데모 무대 ──────────────────────────────────────────
@include stage($stage-orange);
.dt {
box-sizing: border-box;
width: min(272px, 84vw);
background: #fff;
border-radius: $r-card;
padding: $sp-4;
box-shadow: $shadow-raised;
&__label {
display: block;
margin: 0 0 $sp-1;
font-weight: 700;
font-size: 12px;
line-height: 1;
font-family: inherit;
color: $stage-ink;
}
&__input {
box-sizing: border-box;
width: 100%;
border: 2px solid transparent;
border-radius: $r-control;
padding: $sp-3 $sp-4;
font-weight: 600;
font-size: clamp(14px, 4.2vw, 18px);
font-family: inherit;
letter-spacing: .04em;
color: $stage-ink;
background: rgba(23, 20, 26, .06);
transition: border-color $duration $easing, background $duration $easing;
&:focus {
outline: none;
border-color: $color;
background: #fff;
}
}
&__hint {
margin: $sp-1 0 0;
font-size: 11px;
line-height: 1.4;
font-family: inherit;
color: rgba($stage-ink, .72);
}
}
@media (prefers-reduced-motion: reduce) {
.dt__input {
transition: none;
}
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.dt {
box-sizing: border-box;
width: min(272px, 84vw);
background: #fff;
border-radius: 16px;
padding: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.dt__label {
display: block;
margin: 0 0 4px;
font-weight: 700;
font-size: 12px;
line-height: 1;
font-family: inherit;
color: #17141a;
}
.dt__input {
box-sizing: border-box;
width: 100%;
border: 2px solid transparent;
border-radius: 12px;
padding: 12px 16px;
font-weight: 600;
font-size: clamp(14px, 4.2vw, 18px);
font-family: inherit;
letter-spacing: 0.04em;
color: #17141a;
background: rgba(23, 20, 26, 0.06);
transition: border-color 220ms ease-out, background 220ms ease-out;
}
.dt__input:focus {
outline: none;
border-color: #ff4d1f;
background: #fff;
}
.dt__hint {
margin: 4px 0 0;
font-size: 11px;
line-height: 1.4;
font-family: inherit;
color: rgba(23, 20, 26, 0.72);
}
@media (prefers-reduced-motion: reduce) {
.dt__input {
transition: none;
}
}(function () {
var wrap = document.getElementById('wrap');
var input = document.getElementById('dt-input');
function format(raw) {
var digits = raw.replace(/\D/g, '').slice(0, 8);
var out = digits.slice(0, 4);
if (digits.length > 4) out += '/' + digits.slice(4, 6);
if (digits.length > 6) out += '/' + digits.slice(6, 8);
return out;
}
input.addEventListener('input', function () {
input.value = format(input.value);
});
function stopDemo() {
if (!wrap.classList.contains('is-demo')) return;
wrap.classList.remove('is-demo');
clearInterval(timer);
}
input.addEventListener('pointerdown', stopDemo);
input.addEventListener('keydown', stopDemo);
var digits = '20260911';
var raw = '';
var phase = 'focus';
var pauseUntil = 0;
var timer = setInterval(function () {
var now = Date.now();
if (now < pauseUntil) return;
if (phase === 'focus') {
input.focus({ preventScroll: true });
phase = 'type';
return;
}
if (phase === 'type') {
if (raw.length < digits.length) {
raw += digits[raw.length];
input.value = raw;
input.dispatchEvent(new Event('input', { bubbles: true }));
} else {
phase = 'hold';
pauseUntil = now + 900;
}
return;
}
if (phase === 'hold') {
raw = '';
input.value = '';
input.dispatchEvent(new Event('input', { bubbles: true }));
input.blur();
phase = 'blurhold';
pauseUntil = now + 500;
return;
}
if (phase === 'blurhold') {
phase = 'focus';
}
}, 140);
})();import { useState } from "react";
import styles from "./DateAutoSlashInput.module.scss";
export type DateAutoSlashInputProps = {
label?: string;
hint?: string;
};
function formatDate(raw: string): string {
const digits = raw.replace(/\D/g, "").slice(0, 8);
let out = digits.slice(0, 4);
if (digits.length > 4) out += "/" + digits.slice(4, 6);
if (digits.length > 6) out += "/" + digits.slice(6, 8);
return out;
}
export default function DateAutoSlashInput({
label = "생년월일",
hint = "숫자만 입력하면 슬래시가 자동으로 붙어요",
}: DateAutoSlashInputProps) {
const [value, setValue] = useState("");
const onChange = (e: any) => {
setValue(formatDate(e.target.value));
};
return (
<div className={styles.dt}>
<label className={styles.label} htmlFor="dt-input">
{label}
</label>
<input
className={styles.input}
id="dt-input"
type="text"
inputMode="numeric"
placeholder="YYYY/MM/DD"
aria-describedby="dt-hint"
value={value}
onChange={onChange}
/>
<p className={styles.hint} id="dt-hint">
{hint}
</p>
</div>
);
}03 · 日付の自動スラッシュ入力欄 — 計測 累積面積 8.603% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
9桁目以降は切り捨てられます。
function format(raw) {
var digits = raw.replace(/\D/g, '').slice(0, 8);
var out = digits.slice(0, 4);
if (digits.length > 4) out += '/' + digits.slice(4, 6);
if (digits.length > 6) out += '/' + digits.slice(6, 8);
return out;
}
input.addEventListener('input', function () {
input.value = format(input.value);
});
04国番号選択付き電話番号入力欄
何が見えるか
国旗ボタンで国を選ぶと国番号が切り替わり、番号欄はその国の桁の区切りで書き直されます。

<main class="stage">
<div class="phone is-demo" id="wrap">
<span class="phone__label" id="phone-label">電話番号</span>
<div class="phone__row">
<div class="phone__trigger-wrap">
<button type="button" class="phone__trigger" id="trigger"
aria-haspopup="listbox" aria-expanded="false" aria-controls="listbox"
aria-label="国を選択、現在は韓国">
<span class="phone__flag" aria-hidden="true">🇰🇷</span>
<span class="phone__dial">+82</span>
<span class="phone__chevron" aria-hidden="true"></span>
</button>
<ul class="phone__listbox" id="listbox" role="listbox" aria-label="国リスト" tabindex="-1" hidden>
<li role="option" id="opt-kr" class="phone__option" data-code="kr" aria-selected="true">
<span aria-hidden="true">🇰🇷</span> 韓国 <span class="phone__option-dial">+82</span>
</li>
<li role="option" id="opt-us" class="phone__option" data-code="us" aria-selected="false">
<span aria-hidden="true">🇺🇸</span> アメリカ <span class="phone__option-dial">+1</span>
</li>
<li role="option" id="opt-jp" class="phone__option" data-code="jp" aria-selected="false">
<span aria-hidden="true">🇯🇵</span> 日本 <span class="phone__option-dial">+81</span>
</li>
</ul>
</div>
<input class="phone__input" id="number" type="text" inputmode="numeric" placeholder="10 1234 5678" aria-labelledby="phone-label" aria-describedby="phone-hint">
</div>
<p class="phone__hint" id="phone-hint">矢印キーで国を選択 · Enterで確定</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-quick; // 목록 열림/닫힘 시간
$easing: $ease-out; // 빠르게 시작해 부드럽게 멈춤
$color: $subject-blue; // 트리거 포커스·선택 강조 색
// ── 데모 무대 ──────────────────────────────────────────
@include stage($stage-paper);
.phone {
box-sizing: border-box;
width: min(296px, 88vw);
background: $subject-cream;
border-radius: $r-card;
padding: $sp-5;
box-shadow: $shadow-raised;
&__label {
display: block;
margin: 0 0 $sp-2;
font-weight: 700;
font-size: 13px;
line-height: 1;
font-family: inherit;
color: $stage-ink;
}
&__row {
display: flex;
gap: $sp-2;
}
&__trigger-wrap {
position: relative;
flex: 0 0 auto;
}
&__trigger {
box-sizing: border-box;
height: 100%;
display: flex;
align-items: center;
gap: $sp-1;
border: 2px solid transparent;
border-radius: $r-control;
padding: $sp-3 $sp-2;
background: #fff;
font-weight: 600;
font-size: clamp(13px, 3.6vw, 15px);
font-family: inherit;
color: $stage-ink;
white-space: nowrap;
cursor: pointer;
transition: border-color $duration $easing;
&:focus-visible { outline: none; border-color: $color; }
&[aria-expanded="true"] { border-color: $color; }
}
&__flag { font-size: 16px; line-height: 1; }
&__dial { font-variant-numeric: tabular-nums; }
&__chevron {
width: 0;
height: 0;
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-top: 5px solid currentColor;
transition: transform $duration $easing;
}
&__trigger[aria-expanded="true"] &__chevron { transform: rotate(180deg); }
&__input {
box-sizing: border-box;
flex: 1 1 auto;
min-width: 0;
border: 2px solid transparent;
border-radius: $r-control;
padding: $sp-3 $sp-3;
font-weight: 500;
font-size: clamp(14px, 4.2vw, 16px);
font-family: inherit;
color: $stage-ink;
background: #fff;
transition: border-color $duration $easing;
&:focus { outline: none; border-color: $color; }
}
&__hint {
margin: $sp-2 0 0;
font-size: 11px;
line-height: 1.4;
font-family: inherit;
color: rgba($stage-ink, .72);
}
&__listbox {
position: absolute;
top: calc(100% + #{$sp-1});
left: 0;
width: min(190px, 75vw);
max-height: 160px;
overflow-y: auto;
margin: 0;
padding: $sp-1;
list-style: none;
background: #fff;
border-radius: $r-sm;
box-shadow: $shadow-raised;
z-index: 10;
transform-origin: top; // 규칙8: 트리거 아래변에서 펼침
opacity: 0;
transform: scaleY(.9) translateY(-4px);
pointer-events: none;
&.is-open {
opacity: 1;
transform: scaleY(1) translateY(0);
pointer-events: auto;
transition: opacity $duration $easing, transform $duration $easing;
}
}
&__option {
display: flex;
align-items: center;
gap: $sp-1;
padding: $sp-2 $sp-2;
border-radius: $r-xs;
font-size: 13px;
font-weight: 500;
font-family: inherit;
color: $stage-ink;
cursor: pointer;
&-dial {
margin-left: auto;
font-variant-numeric: tabular-nums;
opacity: .7;
}
&--active { background: rgba($color, .12); }
&[aria-selected="true"] { font-weight: 700; color: $color; }
}
}
@media (prefers-reduced-motion: reduce) {
.phone__listbox { transition: none !important; }
.phone__trigger, .phone__input, .phone__chevron { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.phone {
box-sizing: border-box;
width: min(296px, 88vw);
background: #fff7e6;
border-radius: 16px;
padding: 20px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.phone__label {
display: block;
margin: 0 0 8px;
font-weight: 700;
font-size: 13px;
line-height: 1;
font-family: inherit;
color: #17141a;
}
.phone__row {
display: flex;
gap: 8px;
}
.phone__trigger-wrap {
position: relative;
flex: 0 0 auto;
}
.phone__trigger {
box-sizing: border-box;
height: 100%;
display: flex;
align-items: center;
gap: 4px;
border: 2px solid transparent;
border-radius: 12px;
padding: 12px 8px;
background: #fff;
font-weight: 600;
font-size: clamp(13px, 3.6vw, 15px);
font-family: inherit;
color: #17141a;
white-space: nowrap;
cursor: pointer;
transition: border-color 220ms ease-out;
}
.phone__trigger:focus-visible {
outline: none;
border-color: #2f6df6;
}
.phone__trigger[aria-expanded=true] {
border-color: #2f6df6;
}
.phone__flag {
font-size: 16px;
line-height: 1;
}
.phone__dial {
font-variant-numeric: tabular-nums;
}
.phone__chevron {
width: 0;
height: 0;
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-top: 5px solid currentColor;
transition: transform 220ms ease-out;
}
.phone__trigger[aria-expanded=true] .phone__chevron {
transform: rotate(180deg);
}
.phone__input {
box-sizing: border-box;
flex: 1 1 auto;
min-width: 0;
border: 2px solid transparent;
border-radius: 12px;
padding: 12px 12px;
font-weight: 500;
font-size: clamp(14px, 4.2vw, 16px);
font-family: inherit;
color: #17141a;
background: #fff;
transition: border-color 220ms ease-out;
}
.phone__input:focus {
outline: none;
border-color: #2f6df6;
}
.phone__hint {
margin: 8px 0 0;
font-size: 11px;
line-height: 1.4;
font-family: inherit;
color: rgba(23, 20, 26, 0.72);
}
.phone__listbox {
position: absolute;
top: calc(100% + 4px);
left: 0;
width: min(190px, 75vw);
max-height: 160px;
overflow-y: auto;
margin: 0;
padding: 4px;
list-style: none;
background: #fff;
border-radius: 8px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
z-index: 10;
transform-origin: top;
opacity: 0;
transform: scaleY(0.9) translateY(-4px);
pointer-events: none;
}
.phone__listbox.is-open {
opacity: 1;
transform: scaleY(1) translateY(0);
pointer-events: auto;
transition: opacity 220ms ease-out, transform 220ms ease-out;
}
.phone__option {
display: flex;
align-items: center;
gap: 4px;
padding: 8px 8px;
border-radius: 4px;
font-size: 13px;
font-weight: 500;
font-family: inherit;
color: #17141a;
cursor: pointer;
}
.phone__option-dial {
margin-left: auto;
font-variant-numeric: tabular-nums;
opacity: 0.7;
}
.phone__option--active {
background: rgba(47, 109, 246, 0.12);
}
.phone__option[aria-selected=true] {
font-weight: 700;
color: #2f6df6;
}
@media (prefers-reduced-motion: reduce) {
.phone__listbox {
transition: none !important;
}
.phone__trigger, .phone__input, .phone__chevron {
transition: none;
}
}(function () {
var COUNTRIES = {
kr: { name: '韓国', flag: '🇰🇷', dial: '+82', groups: [3, 4, 4], sample: '1012345678' },
us: { name: 'アメリカ', flag: '🇺🇸', dial: '+1', groups: [3, 3, 4], sample: '2025551234' },
jp: { name: '日本', flag: '🇯🇵', dial: '+81', groups: [2, 4, 4], sample: '9012345678' }
};
var ORDER = ['kr', 'us', 'jp'];
var wrap = document.getElementById('wrap');
var trigger = document.getElementById('trigger');
var listbox = document.getElementById('listbox');
var input = document.getElementById('number');
var options = Array.prototype.slice.call(listbox.querySelectorAll('.phone__option'));
var current = 'kr';
var active = 0; // 키보드로 강조된 옵션 인덱스 (열려있을 때만 의미 있음)
var open = false;
var hideTimer = null;
function maxDigits(code) {
var g = COUNTRIES[code].groups, total = 0;
for (var i = 0; i < g.length; i++) total += g[i];
return total;
}
function formatDigits(digits, groups) {
var out = [], i = 0;
for (var g = 0; g < groups.length && i < digits.length; g++) {
out.push(digits.slice(i, i + groups[g]));
i += groups[g];
}
return out.join(' ');
}
function reformatInput() {
var digits = input.value.replace(/\D/g, '').slice(0, maxDigits(current));
input.value = formatDigits(digits, COUNTRIES[current].groups);
}
function highlight(i) {
active = i;
options.forEach(function (o, idx) { o.classList.toggle('phone__option--active', idx === i); });
trigger.setAttribute('aria-activedescendant', options[i].id);
}
function openListbox() {
if (open) return;
open = true;
clearTimeout(hideTimer);
listbox.hidden = false;
void listbox.offsetWidth; // 강제 리플로우 — hidden 해제와 애니메이션을 분리
listbox.classList.add('is-open');
trigger.setAttribute('aria-expanded', 'true');
highlight(ORDER.indexOf(current));
}
function closeListbox(returnFocus) {
if (!open) return;
open = false;
listbox.classList.remove('is-open');
trigger.setAttribute('aria-expanded', 'false');
trigger.removeAttribute('aria-activedescendant');
options.forEach(function (o) { o.classList.remove('phone__option--active'); });
hideTimer = setTimeout(function () { if (!open) listbox.hidden = true; }, 220);
if (returnFocus) trigger.focus({ preventScroll: true });
}
function selectCountry(code) {
current = code;
options.forEach(function (o) { o.setAttribute('aria-selected', String(o.dataset.code === code)); });
var c = COUNTRIES[code];
trigger.querySelector('.phone__flag').textContent = c.flag;
trigger.querySelector('.phone__dial').textContent = c.dial;
trigger.setAttribute('aria-label', '国を選択、現在は ' + c.name);
reformatInput();
}
trigger.addEventListener('click', function () {
if (open) closeListbox(true); else openListbox();
});
trigger.addEventListener('keydown', function (e) {
if (!open) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openListbox();
}
return;
}
if (e.key === 'ArrowDown') { e.preventDefault(); highlight((active + 1) % options.length); }
else if (e.key === 'ArrowUp') { e.preventDefault(); highlight((active - 1 + options.length) % options.length); }
else if (e.key === 'Enter') { e.preventDefault(); selectCountry(options[active].dataset.code); closeListbox(true); }
else if (e.key === 'Escape') { e.preventDefault(); closeListbox(true); }
else if (e.key === 'Tab') { closeListbox(false); }
});
options.forEach(function (o, idx) {
o.addEventListener('click', function () { selectCountry(o.dataset.code); closeListbox(true); });
o.addEventListener('pointerenter', function () { highlight(idx); });
});
document.addEventListener('pointerdown', function (e) {
if (open && !wrap.contains(e.target)) closeListbox(false);
});
input.addEventListener('input', reformatInput);
// ── 실사용자가 손대면 데모를 영구 정지 (캡처 단계 — 실제 동작을 막지 않는다) ──
wrap.addEventListener('pointerdown', stopDemo, true);
wrap.addEventListener('keydown', stopDemo, true);
function stopDemo() {
if (!wrap.classList.contains('is-demo')) return;
wrap.classList.remove('is-demo');
clearTimeout(demoTimer);
}
// ── 자동 데모 루프: 실제 코드 경로(openListbox·selectCountry·input 이벤트)를 그대로 돈다 ──
var demoTimer = null;
var orderIdx = 0;
function typewrite(code, i, cb) {
if (!wrap.classList.contains('is-demo')) return;
var sample = COUNTRIES[code].sample;
if (i <= sample.length) {
input.value = formatDigits(sample.slice(0, i), COUNTRIES[code].groups);
demoTimer = setTimeout(function () { typewrite(code, i + 1, cb); }, 90);
} else {
demoTimer = setTimeout(cb, 900);
}
}
function demoStep() {
if (!wrap.classList.contains('is-demo')) return;
var code = ORDER[orderIdx % ORDER.length];
orderIdx++;
openListbox();
highlight(ORDER.indexOf(code));
demoTimer = setTimeout(function () {
if (!wrap.classList.contains('is-demo')) return;
selectCountry(code);
closeListbox(false);
demoTimer = setTimeout(function () {
typewrite(code, 1, function () {
input.value = '';
demoTimer = setTimeout(demoStep, 260);
});
}, 260);
}, 500);
}
demoTimer = setTimeout(demoStep, 500);
})();import { useEffect, useRef, useState } from "react";
import styles from "./PhoneCountryInput.module.scss";
type CountryCode = "kr" | "us" | "jp";
type Country = {
name: string;
flag: string;
dial: string;
groups: number[];
};
const COUNTRIES: Record<CountryCode, Country> = {
kr: { name: "대한민국", flag: "🇰🇷", dial: "+82", groups: [3, 4, 4] },
us: { name: "미국", flag: "🇺🇸", dial: "+1", groups: [3, 3, 4] },
jp: { name: "일본", flag: "🇯🇵", dial: "+81", groups: [2, 4, 4] },
};
const ORDER: CountryCode[] = ["kr", "us", "jp"];
function formatDigits(digits: string, groups: number[]): string {
const out: string[] = [];
let i = 0;
for (let g = 0; g < groups.length && i < digits.length; g++) {
out.push(digits.slice(i, i + groups[g]));
i += groups[g];
}
return out.join(" ");
}
function maxDigits(groups: number[]): number {
return groups.reduce((a, b) => a + b, 0);
}
export default function PhoneCountryInput() {
const [country, setCountry] = useState<CountryCode>("kr");
const [number, setNumber] = useState("");
const [open, setOpen] = useState(false);
const [active, setActive] = useState(0);
const wrapRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const current = COUNTRIES[country];
const openList = () => {
setActive(ORDER.indexOf(country));
setOpen(true);
};
const closeList = (returnFocus: boolean) => {
setOpen(false);
if (returnFocus) triggerRef.current?.focus({ preventScroll: true });
};
const selectCountry = (code: CountryCode) => {
setCountry(code);
setNumber((prev) => {
const digits = prev.replace(/\D/g, "").slice(0, maxDigits(COUNTRIES[code].groups));
return formatDigits(digits, COUNTRIES[code].groups);
});
};
const onNumberChange = (e: any) => {
const digits = String(e.target.value).replace(/\D/g, "").slice(0, maxDigits(current.groups));
setNumber(formatDigits(digits, current.groups));
};
const onTriggerKeyDown = (e: any) => {
if (!open) {
if (["ArrowDown", "ArrowUp", "Enter", " "].indexOf(e.key) !== -1) {
e.preventDefault();
openList();
}
return;
}
if (e.key === "ArrowDown") {
e.preventDefault();
setActive((a) => (a + 1) % ORDER.length);
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActive((a) => (a - 1 + ORDER.length) % ORDER.length);
} else if (e.key === "Enter") {
e.preventDefault();
selectCountry(ORDER[active]);
closeList(true);
} else if (e.key === "Escape") {
e.preventDefault();
closeList(true);
}
};
useEffect(() => {
if (!open) return;
const onDocPointerDown = (e: PointerEvent) => {
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) closeList(false);
};
document.addEventListener("pointerdown", onDocPointerDown);
return () => document.removeEventListener("pointerdown", onDocPointerDown);
}, [open]);
return (
<div className={styles.phone} ref={wrapRef}>
<span className={styles.label} id="phone-label">전화번호</span>
<div className={styles.row}>
<div className={styles.triggerWrap}>
<button
ref={triggerRef}
type="button"
className={styles.trigger}
aria-haspopup="listbox"
aria-expanded={open}
aria-controls="phone-listbox"
aria-activedescendant={open ? `phone-opt-${ORDER[active]}` : undefined}
aria-label={`국가 선택, 현재 ${current.name}`}
onClick={() => (open ? closeList(true) : openList())}
onKeyDown={onTriggerKeyDown}
>
<span className={styles.flag} aria-hidden="true">{current.flag}</span>
<span className={styles.dial}>{current.dial}</span>
<span className={styles.chevron + (open ? " " + styles.chevronOpen : "")} aria-hidden="true" />
</button>
{open && (
<ul className={styles.listbox} id="phone-listbox" role="listbox" aria-label="국가 목록">
{ORDER.map((code, i) => (
<li
key={code}
role="option"
id={`phone-opt-${code}`}
aria-selected={code === country}
className={styles.option + (i === active ? " " + styles.optionActive : "")}
onClick={() => {
selectCountry(code);
closeList(true);
}}
onPointerEnter={() => setActive(i)}
>
<span aria-hidden="true">{COUNTRIES[code].flag}</span> {COUNTRIES[code].name}{" "}
<span className={styles.optionDial}>{COUNTRIES[code].dial}</span>
</li>
))}
</ul>
)}
</div>
<input
className={styles.input}
type="text"
inputMode="numeric"
placeholder="10 1234 5678"
aria-labelledby="phone-label"
value={number}
onChange={onNumberChange}
/>
</div>
</div>
);
}04 · 国番号選択付き電話番号入力欄 — 計測 累積面積 14.364% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
韓国 +82 は 3·4·4、アメリカ +1 は 3·3·4、日本 +81 は 2·4·4 で切られるため、同じ10桁でも読み方が変わります。
trigger.addEventListener('keydown', function (e) {
if (!open) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openListbox();
}
return;
}
if (e.key === 'ArrowDown') { e.preventDefault(); highlight((active + 1) % options.length); }
else if (e.key === 'ArrowUp') { e.preventDefault(); highlight((active - 1 + options.length) % options.length); }
else if (e.key === 'Enter') { e.preventDefault(); selectCountry(options[active].dataset.code); closeListbox(true); }
else if (e.key === 'Escape') { e.preventDefault(); closeListbox(true); }
else if (e.key === 'Tab') { closeListbox(false); }
});
05住所オートコンプリート風入力欄
画面で起きること
住所を打つと、下に出る候補は固定された5行のうち一致するものだけが残り、矢印キーと Enter で選んで欄を埋められます。入力欄には role="combobox" と aria-autocomplete="list" が付いています。

<main class="stage">
<div class="addr is-demo" id="wrap">
<label class="addr__label" for="addr-input">配送先住所</label>
<div class="addr__field">
<input class="addr__input" id="addr-input" type="text" role="combobox"
aria-expanded="false" aria-controls="addr-listbox" aria-autocomplete="list"
aria-haspopup="listbox" aria-describedby="addr-hint" autocomplete="off"
placeholder="住所を入力してください">
<ul class="addr__listbox" id="addr-listbox" role="listbox" aria-label="住所候補" hidden></ul>
</div>
<p class="addr__hint" id="addr-hint">矢印キーで選び · Enterで入力</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-quick; // 후보 목록 열림 시간
$easing: $ease-out;
$color: $subject-mint; // 밝고 상큼한 포인트 색 (어두운 무대 대비)
// ── 데모 무대 ──────────────────────────────────────────
@include stage($stage-ink);
.addr {
box-sizing: border-box;
width: min(300px, 88vw);
background: $subject-cream;
border-radius: $r-card;
padding: $sp-5;
box-shadow: $shadow-raised;
&__label {
display: block;
margin: 0 0 $sp-2;
font-weight: 700;
font-size: 13px;
line-height: 1;
font-family: inherit;
color: $stage-ink;
}
&__field {
position: relative;
}
&__input {
box-sizing: border-box;
width: 100%;
border: 2px solid transparent;
border-radius: $r-control;
padding: $sp-3 $sp-4;
font-weight: 500;
font-size: clamp(14px, 4.2vw, 16px);
font-family: inherit;
color: $stage-ink;
background: #fff;
transition: border-color $duration $easing;
&:focus { outline: none; border-color: $color; }
}
&__listbox {
position: absolute;
top: calc(100% + #{$sp-1});
left: 0;
width: 100%;
box-sizing: border-box;
max-height: 160px;
overflow-y: auto;
margin: 0;
padding: $sp-1;
list-style: none;
background: #fff;
border-radius: $r-sm;
box-shadow: $shadow-raised;
z-index: 10;
transform-origin: top; // 규칙8: 입력창 아래변에서 펼침
opacity: 0;
transform: scaleY(.92) translateY(-4px);
pointer-events: none;
&.is-open {
opacity: 1;
transform: scaleY(1) translateY(0);
pointer-events: auto;
transition: opacity $duration $easing, transform $duration $easing;
}
}
&__option {
padding: $sp-2 $sp-3;
border-radius: $r-xs;
font-size: 13px;
font-weight: 500;
font-family: inherit;
color: $stage-ink;
cursor: pointer;
&--active { background: rgba($color, .28); }
}
&__hint {
margin: $sp-2 0 0;
font-size: 11px;
line-height: 1.4;
font-family: inherit;
color: rgba($stage-ink, .72);
}
}
@media (prefers-reduced-motion: reduce) {
.addr__listbox { transition: none !important; }
.addr__input { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.addr {
box-sizing: border-box;
width: min(300px, 88vw);
background: #fff7e6;
border-radius: 16px;
padding: 20px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.addr__label {
display: block;
margin: 0 0 8px;
font-weight: 700;
font-size: 13px;
line-height: 1;
font-family: inherit;
color: #17141a;
}
.addr__field {
position: relative;
}
.addr__input {
box-sizing: border-box;
width: 100%;
border: 2px solid transparent;
border-radius: 12px;
padding: 12px 16px;
font-weight: 500;
font-size: clamp(14px, 4.2vw, 16px);
font-family: inherit;
color: #17141a;
background: #fff;
transition: border-color 220ms ease-out;
}
.addr__input:focus {
outline: none;
border-color: #4cd4a6;
}
.addr__listbox {
position: absolute;
top: calc(100% + 4px);
left: 0;
width: 100%;
box-sizing: border-box;
max-height: 160px;
overflow-y: auto;
margin: 0;
padding: 4px;
list-style: none;
background: #fff;
border-radius: 8px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
z-index: 10;
transform-origin: top;
opacity: 0;
transform: scaleY(0.92) translateY(-4px);
pointer-events: none;
}
.addr__listbox.is-open {
opacity: 1;
transform: scaleY(1) translateY(0);
pointer-events: auto;
transition: opacity 220ms ease-out, transform 220ms ease-out;
}
.addr__option {
padding: 8px 12px;
border-radius: 4px;
font-size: 13px;
font-weight: 500;
font-family: inherit;
color: #17141a;
cursor: pointer;
}
.addr__option--active {
background: rgba(76, 212, 166, 0.28);
}
.addr__hint {
margin: 8px 0 0;
font-size: 11px;
line-height: 1.4;
font-family: inherit;
color: rgba(23, 20, 26, 0.72);
}
@media (prefers-reduced-motion: reduce) {
.addr__listbox {
transition: none !important;
}
.addr__input {
transition: none;
}
}(function () {
var ADDRESSES = [
'ソウル江南区テヘラン路14',
'ソウル麻浦区ワールドカップ路3',
'釜山海雲台区センタム路99',
'大邱寿城区東大邱路7',
'仁川延寿区コンベンシア大路5'
];
var wrap = document.getElementById('wrap');
var input = document.getElementById('addr-input');
var listbox = document.getElementById('addr-listbox');
var matches = [];
var active = -1; // -1 = 강조 없음 (입력한 글자 그대로)
var open = false;
var hideTimer = null;
function render(list) {
listbox.innerHTML = '';
list.forEach(function (text, i) {
var li = document.createElement('li');
li.className = 'addr__option';
li.id = 'addr-opt-' + i;
li.setAttribute('role', 'option');
li.setAttribute('aria-selected', 'false');
li.textContent = text;
li.addEventListener('click', function () { selectOption(i, true); });
li.addEventListener('pointerenter', function () { setActive(i); });
listbox.appendChild(li);
});
}
function setActive(i) {
active = i;
var opts = listbox.querySelectorAll('.addr__option');
opts.forEach(function (o, idx) {
var on = idx === i;
o.classList.toggle('addr__option--active', on);
o.setAttribute('aria-selected', String(on));
});
if (i > -1 && opts[i]) input.setAttribute('aria-activedescendant', opts[i].id);
else input.removeAttribute('aria-activedescendant');
}
function openList() {
if (open) return;
open = true;
clearTimeout(hideTimer);
listbox.hidden = false;
void listbox.offsetWidth;
listbox.classList.add('is-open');
input.setAttribute('aria-expanded', 'true');
}
function closeList() {
if (!open) return;
open = false;
listbox.classList.remove('is-open');
input.setAttribute('aria-expanded', 'false');
input.removeAttribute('aria-activedescendant');
hideTimer = setTimeout(function () { if (!open) listbox.hidden = true; }, 220);
}
function selectOption(i, focusInput) {
var text = matches[i];
if (text === undefined) return;
input.value = text;
closeList();
setActive(-1);
matches = [];
if (focusInput) input.focus({ preventScroll: true });
}
function filterAndRender(query) {
matches = query ? ADDRESSES.filter(function (a) { return a.indexOf(query) > -1; }) : [];
if (query && matches.length) {
render(matches);
setActive(-1);
openList();
} else {
closeList();
}
}
input.addEventListener('input', function () { filterAndRender(input.value.trim()); });
input.addEventListener('keydown', function (e) {
if (e.key === 'ArrowDown') {
e.preventDefault();
if (!open && matches.length) { openList(); setActive(0); return; }
if (open && matches.length) setActive(Math.min(active + 1, matches.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (open) { if (active <= 0) setActive(-1); else setActive(active - 1); }
} else if (e.key === 'Enter') {
if (open && active > -1) { e.preventDefault(); selectOption(active, true); }
} else if (e.key === 'Escape') {
if (open) { e.preventDefault(); closeList(); setActive(-1); }
}
});
document.addEventListener('pointerdown', function (e) {
if (open && !wrap.contains(e.target)) closeList();
});
// ── 실사용자가 손대면 데모를 영구 정지 ──
wrap.addEventListener('pointerdown', stopDemo, true);
wrap.addEventListener('keydown', stopDemo, true);
function stopDemo() {
if (!wrap.classList.contains('is-demo')) return;
wrap.classList.remove('is-demo');
clearTimeout(demoTimer);
}
// ── 자동 데모 루프: input 이벤트를 실제로 디스패치해 진짜 필터·선택 경로를 돈다 ──
var demoTimer = null;
var QUERIES = ['ソウル江南', '釜山海雲台'];
var qIdx = 0;
function dispatchInput() {
input.dispatchEvent(new Event('input', { bubbles: true }));
}
function typewrite(str, i, cb) {
if (!wrap.classList.contains('is-demo')) return;
if (i <= str.length) {
input.value = str.slice(0, i);
dispatchInput();
demoTimer = setTimeout(function () { typewrite(str, i + 1, cb); }, 110);
} else {
demoTimer = setTimeout(cb, 500);
}
}
function demoStep() {
if (!wrap.classList.contains('is-demo')) return;
var query = QUERIES[qIdx % QUERIES.length];
qIdx++;
typewrite(query, 1, function () {
if (!wrap.classList.contains('is-demo')) return;
if (matches.length) {
setActive(0);
demoTimer = setTimeout(function () {
if (!wrap.classList.contains('is-demo')) return;
selectOption(0, false);
demoTimer = setTimeout(function () {
if (!wrap.classList.contains('is-demo')) return;
input.value = '';
dispatchInput();
demoTimer = setTimeout(demoStep, 400);
}, 900);
}, 450);
} else {
demoTimer = setTimeout(demoStep, 400);
}
});
}
demoTimer = setTimeout(demoStep, 500);
})();import { useRef, useState } from "react";
import styles from "./AddressAutocompleteInput.module.scss";
const ADDRESSES = [
"서울 강남구 테헤란로 14",
"서울 마포구 월드컵로 3",
"부산 해운대구 센텀로 99",
"대구 수성구 동대구로 7",
"인천 연수구 컨벤시아대로 5",
];
export default function AddressAutocompleteInput() {
const [value, setValue] = useState("");
const [matches, setMatches] = useState<string[]>([]);
const [active, setActive] = useState(-1);
const [open, setOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const wrapRef = useRef<HTMLDivElement>(null);
const filter = (query: string) => {
const trimmed = query.trim();
const next = trimmed ? ADDRESSES.filter((a) => a.indexOf(trimmed) > -1) : [];
setMatches(next);
setActive(-1);
setOpen(Boolean(trimmed) && next.length > 0);
};
const onChange = (e: any) => {
const v = String(e.target.value);
setValue(v);
filter(v);
};
const selectOption = (i: number) => {
const text = matches[i];
if (text === undefined) return;
setValue(text);
setOpen(false);
setActive(-1);
setMatches([]);
inputRef.current?.focus({ preventScroll: true });
};
const onKeyDown = (e: any) => {
if (e.key === "ArrowDown") {
e.preventDefault();
if (!open && matches.length) { setOpen(true); setActive(0); return; }
if (open && matches.length) setActive((a) => Math.min(a + 1, matches.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
if (open) setActive((a) => (a <= 0 ? -1 : a - 1));
} else if (e.key === "Enter") {
if (open && active > -1) {
e.preventDefault();
selectOption(active);
}
} else if (e.key === "Escape") {
if (open) {
e.preventDefault();
setOpen(false);
setActive(-1);
}
}
};
const activeId = active > -1 ? `addr-opt-${active}` : undefined;
return (
<div className={styles.addr} ref={wrapRef}>
<label className={styles.label} htmlFor="addr-input">배송지 주소</label>
<div className={styles.field}>
<input
ref={inputRef}
id="addr-input"
className={styles.input}
type="text"
role="combobox"
aria-expanded={open}
aria-controls="addr-listbox"
aria-autocomplete="list"
aria-haspopup="listbox"
aria-describedby="addr-hint"
aria-activedescendant={activeId}
autoComplete="off"
placeholder="주소를 입력하세요"
value={value}
onChange={onChange}
onKeyDown={onKeyDown}
onBlur={() => setOpen(false)}
/>
{open && matches.length > 0 && (
<ul className={styles.listbox} id="addr-listbox" role="listbox" aria-label="주소 후보">
{matches.map((text, i) => (
<li
key={text}
id={`addr-opt-${i}`}
role="option"
aria-selected={i === active}
className={styles.option + (i === active ? " " + styles.optionActive : "")}
onMouseDown={(e: any) => e.preventDefault()}
onClick={() => selectOption(i)}
onPointerEnter={() => setActive(i)}
>
{text}
</li>
))}
</ul>
)}
</div>
<p className={styles.hint} id="addr-hint">화살표 키로 고르고 Enter로 채우기</p>
</div>
);
}05 · 住所オートコンプリート風入力欄 — 計測 累積面積 15.744% · 動いたフレーム 7/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
候補はあらかじめ決めた住所から選ぶ静的な一覧なので API キーは要りません。
input.addEventListener('keydown', function (e) {
if (e.key === 'ArrowDown') {
e.preventDefault();
if (!open && matches.length) { openList(); setActive(0); return; }
if (open && matches.length) setActive(Math.min(active + 1, matches.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (open) { if (active <= 0) setActive(-1); else setActive(active - 1); }
} else if (e.key === 'Enter') {
if (open && active > -1) { e.preventDefault(); selectOption(active, true); }
} else if (e.key === 'Escape') {
if (open) { e.preventDefault(); closeList(); setActive(-1); }
}
});
06複数選択コンボボックス
どんな動きか
デザイン・開発・マーケティング・企画の4行から複数を選ぶと、選ぶたびにピル型のチップが入力欄の中に積み上がります。一覧には aria-multiselectable="true" が付いているので2つ目を選んでも1つ目が解除されません。

<main class="stage">
<div class="multi is-demo" id="wrap">
<span class="multi__label" id="multi-label">興味のある分野</span>
<button type="button" class="multi__trigger" id="trigger"
aria-haspopup="listbox" aria-expanded="false" aria-controls="multi-listbox"
aria-labelledby="multi-label trigger" aria-describedby="multi-hint">
<span class="multi__chips" id="chips">
<span class="multi__placeholder" id="placeholder">興味のある分野を選択</span>
</span>
<span class="multi__chevron" aria-hidden="true"></span>
</button>
<ul class="multi__listbox" id="multi-listbox" role="listbox" aria-multiselectable="true" aria-label="分野リスト" tabindex="-1" hidden>
<li role="option" id="multi-opt-0" class="multi__option" data-value="design" data-label="デザイン" aria-selected="false">
<span class="multi__check" aria-hidden="true"></span> デザイン
</li>
<li role="option" id="multi-opt-1" class="multi__option" data-value="dev" data-label="開発" aria-selected="false">
<span class="multi__check" aria-hidden="true"></span> 開発
</li>
<li role="option" id="multi-opt-2" class="multi__option" data-value="marketing" data-label="マーケティング" aria-selected="false">
<span class="multi__check" aria-hidden="true"></span> マーケティング
</li>
<li role="option" id="multi-opt-3" class="multi__option" data-value="planning" data-label="企画" aria-selected="false">
<span class="multi__check" aria-hidden="true"></span> 企画
</li>
</ul>
<p class="multi__hint" id="multi-hint">矢印で移動 · Enterで選択/解除 · Escで閉じる</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-quick; // 목록 열림 · 칩 등장 시간
$easing: $ease-pop; // 칩이 톡 튀어나오는 느낌
$color: $subject-pink; // 밝고 귀여운 포인트 색
// ── 데모 무대 ──────────────────────────────────────────
@include stage($stage-yellow);
.multi {
box-sizing: border-box;
position: relative;
width: min(300px, 88vw);
background: $subject-cream;
border-radius: $r-card;
padding: $sp-5;
box-shadow: $shadow-raised;
&__label {
display: block;
margin: 0 0 $sp-2;
font-weight: 700;
font-size: 13px;
line-height: 1;
font-family: inherit;
color: $stage-ink;
}
&__trigger {
box-sizing: border-box;
width: 100%;
display: flex;
align-items: center;
gap: $sp-2;
border: 2px solid transparent;
border-radius: $r-control;
padding: $sp-3 $sp-4;
background: #fff;
font-family: inherit;
font-size: clamp(14px, 4vw, 16px);
color: $stage-ink;
text-align: left;
cursor: pointer;
transition: border-color $duration $easing;
&:focus-visible { outline: none; border-color: $color; }
&[aria-expanded="true"] { border-color: $color; }
}
&__chips {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: $sp-1;
flex: 1 1 auto;
min-width: 0;
min-height: 20px;
}
&__placeholder {
color: rgba($stage-ink, .72);
font-weight: 500;
}
&__chip {
display: inline-flex;
align-items: center;
background: $color;
color: #fff;
border-radius: $r-pill;
padding: $sp-1 $sp-2;
font-size: 12px;
font-weight: 700;
font-family: inherit;
animation: chip-pop $duration $easing both;
}
&__chevron {
flex: 0 0 auto;
width: 0;
height: 0;
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-top: 5px solid currentColor;
transition: transform $duration $easing;
}
&__trigger[aria-expanded="true"] &__chevron { transform: rotate(180deg); }
&__listbox {
position: absolute;
top: calc(100% + #{$sp-1});
left: $sp-5;
right: $sp-5;
max-height: 200px;
overflow-y: auto;
margin: 0;
padding: $sp-1;
list-style: none;
background: #fff;
border-radius: $r-sm;
box-shadow: $shadow-raised;
z-index: 10;
transform-origin: top; // 규칙8: 트리거 아래변에서 펼침
opacity: 0;
transform: scaleY(.92) translateY(-4px);
pointer-events: none;
&.is-open {
opacity: 1;
transform: scaleY(1) translateY(0);
pointer-events: auto;
transition: opacity $duration $easing, transform $duration $easing;
}
}
&__option {
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-2 $sp-3;
border-radius: $r-xs;
font-size: 13px;
font-weight: 500;
font-family: inherit;
color: $stage-ink;
cursor: pointer;
&--active { background: rgba($color, .18); }
}
&__check {
box-sizing: border-box;
flex: 0 0 auto;
position: relative;
width: 14px;
height: 14px;
border-radius: $r-xs;
border: 2px solid rgba($stage-ink, .3);
background: #fff;
transition: background $duration $easing, border-color $duration $easing;
}
&__option[aria-selected="true"] &__check {
background: $color;
border-color: $color;
}
&__option[aria-selected="true"] &__check::after {
content: "";
position: absolute;
left: 3px;
top: -1px;
width: 4px;
height: 8px;
border-right: 2px solid #fff;
border-bottom: 2px solid #fff;
transform: rotate(40deg);
}
&__hint {
margin: $sp-2 0 0;
font-size: 11px;
line-height: 1.4;
font-family: inherit;
color: rgba($stage-ink, .72);
}
}
@keyframes chip-pop {
0% { transform: scale(.5); opacity: 0; }
70% { transform: scale(1.06); opacity: 1; }
100% { transform: scale(1); opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.multi__listbox { transition: none !important; }
.multi__trigger, .multi__chevron, .multi__check { transition: none; }
.multi__chip { animation: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.multi {
box-sizing: border-box;
position: relative;
width: min(300px, 88vw);
background: #fff7e6;
border-radius: 16px;
padding: 20px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.multi__label {
display: block;
margin: 0 0 8px;
font-weight: 700;
font-size: 13px;
line-height: 1;
font-family: inherit;
color: #17141a;
}
.multi__trigger {
box-sizing: border-box;
width: 100%;
display: flex;
align-items: center;
gap: 8px;
border: 2px solid transparent;
border-radius: 12px;
padding: 12px 16px;
background: #fff;
font-family: inherit;
font-size: clamp(14px, 4vw, 16px);
color: #17141a;
text-align: left;
cursor: pointer;
transition: border-color 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.multi__trigger:focus-visible {
outline: none;
border-color: #ff9ebb;
}
.multi__trigger[aria-expanded=true] {
border-color: #ff9ebb;
}
.multi__chips {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
flex: 1 1 auto;
min-width: 0;
min-height: 20px;
}
.multi__placeholder {
color: rgba(23, 20, 26, 0.72);
font-weight: 500;
}
.multi__chip {
display: inline-flex;
align-items: center;
background: #ff9ebb;
color: #fff;
border-radius: 999px;
padding: 4px 8px;
font-size: 12px;
font-weight: 700;
font-family: inherit;
animation: chip-pop 220ms cubic-bezier(0.2, 1.4, 0.4, 1) both;
}
.multi__chevron {
flex: 0 0 auto;
width: 0;
height: 0;
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-top: 5px solid currentColor;
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.multi__trigger[aria-expanded=true] .multi__chevron {
transform: rotate(180deg);
}
.multi__listbox {
position: absolute;
top: calc(100% + 4px);
left: 20px;
right: 20px;
max-height: 200px;
overflow-y: auto;
margin: 0;
padding: 4px;
list-style: none;
background: #fff;
border-radius: 8px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
z-index: 10;
transform-origin: top;
opacity: 0;
transform: scaleY(0.92) translateY(-4px);
pointer-events: none;
}
.multi__listbox.is-open {
opacity: 1;
transform: scaleY(1) translateY(0);
pointer-events: auto;
transition: opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1), transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.multi__option {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 4px;
font-size: 13px;
font-weight: 500;
font-family: inherit;
color: #17141a;
cursor: pointer;
}
.multi__option--active {
background: rgba(255, 158, 187, 0.18);
}
.multi__check {
box-sizing: border-box;
flex: 0 0 auto;
position: relative;
width: 14px;
height: 14px;
border-radius: 4px;
border: 2px solid rgba(23, 20, 26, 0.3);
background: #fff;
transition: background 220ms cubic-bezier(0.2, 1.4, 0.4, 1), border-color 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.multi__option[aria-selected=true] .multi__check {
background: #ff9ebb;
border-color: #ff9ebb;
}
.multi__option[aria-selected=true] .multi__check::after {
content: "";
position: absolute;
left: 3px;
top: -1px;
width: 4px;
height: 8px;
border-right: 2px solid #fff;
border-bottom: 2px solid #fff;
transform: rotate(40deg);
}
.multi__hint {
margin: 8px 0 0;
font-size: 11px;
line-height: 1.4;
font-family: inherit;
color: rgba(23, 20, 26, 0.72);
}
@keyframes chip-pop {
0% {
transform: scale(0.5);
opacity: 0;
}
70% {
transform: scale(1.06);
opacity: 1;
}
100% {
transform: scale(1);
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.multi__listbox {
transition: none !important;
}
.multi__trigger, .multi__chevron, .multi__check {
transition: none;
}
.multi__chip {
animation: none;
}
}(function () {
var wrap = document.getElementById('wrap');
var trigger = document.getElementById('trigger');
var listbox = document.getElementById('multi-listbox');
var chips = document.getElementById('chips');
var placeholder = document.getElementById('placeholder');
var options = Array.prototype.slice.call(listbox.querySelectorAll('.multi__option'));
var selected = []; // data-value 배열, 옵션 순서 유지
var active = 0;
var open = false;
var hideTimer = null;
function highlight(i) {
active = i;
options.forEach(function (o, idx) { o.classList.toggle('multi__option--active', idx === i); });
trigger.setAttribute('aria-activedescendant', options[i].id);
}
function openListbox() {
if (open) return;
open = true;
clearTimeout(hideTimer);
listbox.hidden = false;
void listbox.offsetWidth;
listbox.classList.add('is-open');
trigger.setAttribute('aria-expanded', 'true');
highlight(active);
}
function closeListbox(returnFocus) {
if (!open) return;
open = false;
listbox.classList.remove('is-open');
trigger.setAttribute('aria-expanded', 'false');
trigger.removeAttribute('aria-activedescendant');
options.forEach(function (o) { o.classList.remove('multi__option--active'); });
hideTimer = setTimeout(function () { if (!open) listbox.hidden = true; }, 220);
if (returnFocus) trigger.focus({ preventScroll: true });
}
function renderChips() {
chips.innerHTML = '';
if (!selected.length) {
chips.appendChild(placeholder);
return;
}
options.forEach(function (o) {
if (selected.indexOf(o.dataset.value) === -1) return;
var chip = document.createElement('span');
chip.className = 'multi__chip';
chip.textContent = o.dataset.label;
chips.appendChild(chip);
});
}
function toggleOption(i) {
var o = options[i];
var isSel = o.getAttribute('aria-selected') === 'true';
o.setAttribute('aria-selected', String(!isSel));
if (isSel) selected = selected.filter(function (v) { return v !== o.dataset.value; });
else selected.push(o.dataset.value);
renderChips();
}
trigger.addEventListener('click', function () {
if (open) closeListbox(true); else openListbox();
});
trigger.addEventListener('keydown', function (e) {
if (!open) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openListbox();
}
return;
}
if (e.key === 'ArrowDown') { e.preventDefault(); highlight((active + 1) % options.length); }
else if (e.key === 'ArrowUp') { e.preventDefault(); highlight((active - 1 + options.length) % options.length); }
else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleOption(active); }
else if (e.key === 'Escape') { e.preventDefault(); closeListbox(true); }
else if (e.key === 'Tab') { closeListbox(false); }
});
options.forEach(function (o, idx) {
o.addEventListener('click', function () { highlight(idx); toggleOption(idx); });
o.addEventListener('pointerenter', function () { highlight(idx); });
});
document.addEventListener('pointerdown', function (e) {
if (open && !wrap.contains(e.target)) closeListbox(false);
});
renderChips();
// ── 실사용자가 손대면 데모를 영구 정지 ──
wrap.addEventListener('pointerdown', stopDemo, true);
wrap.addEventListener('keydown', stopDemo, true);
function stopDemo() {
if (!wrap.classList.contains('is-demo')) return;
wrap.classList.remove('is-demo');
clearTimeout(demoTimer);
}
// ── 자동 데모 루프: 실제 openListbox·toggleOption·closeListbox 경로를 그대로 돈다 ──
var demoTimer = null;
function demoLoop() {
var steps = [
[function () { openListbox(); }, 500],
[function () { highlight(0); toggleOption(0); }, 550],
[function () { highlight(1); toggleOption(1); }, 900],
[function () { highlight(0); toggleOption(0); }, 300],
[function () { highlight(1); toggleOption(1); }, 300],
[function () { closeListbox(false); }, 500]
];
var i = 0;
function next() {
if (!wrap.classList.contains('is-demo')) return;
if (i >= steps.length) { demoTimer = setTimeout(demoLoop, 300); return; }
var s = steps[i++];
s[0]();
demoTimer = setTimeout(next, s[1]);
}
next();
}
demoTimer = setTimeout(demoLoop, 500);
})();import { useEffect, useRef, useState } from "react";
import styles from "./MultiSelectCombobox.module.scss";
type Option = { value: string; label: string };
const OPTIONS: Option[] = [
{ value: "design", label: "디자인" },
{ value: "dev", label: "개발" },
{ value: "marketing", label: "마케팅" },
{ value: "planning", label: "기획" },
];
export default function MultiSelectCombobox() {
const [open, setOpen] = useState(false);
const [active, setActive] = useState(0);
const [selected, setSelected] = useState<string[]>([]);
const wrapRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const openList = () => setOpen(true);
const closeList = (returnFocus: boolean) => {
setOpen(false);
if (returnFocus) triggerRef.current?.focus({ preventScroll: true });
};
const toggleOption = (i: number) => {
const value = OPTIONS[i].value;
setSelected((prev) =>
prev.indexOf(value) > -1 ? prev.filter((v) => v !== value) : [...prev, value]
);
};
const onTriggerKeyDown = (e: any) => {
if (!open) {
if (["ArrowDown", "ArrowUp", "Enter", " "].indexOf(e.key) !== -1) {
e.preventDefault();
openList();
}
return;
}
if (e.key === "ArrowDown") {
e.preventDefault();
setActive((a) => (a + 1) % OPTIONS.length);
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActive((a) => (a - 1 + OPTIONS.length) % OPTIONS.length);
} else if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
toggleOption(active);
} else if (e.key === "Escape") {
e.preventDefault();
closeList(true);
}
};
useEffect(() => {
if (!open) return;
const onDocPointerDown = (e: PointerEvent) => {
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) closeList(false);
};
document.addEventListener("pointerdown", onDocPointerDown);
return () => document.removeEventListener("pointerdown", onDocPointerDown);
}, [open]);
const chips = OPTIONS.filter((o) => selected.indexOf(o.value) > -1);
return (
<div className={styles.multi} ref={wrapRef}>
<span className={styles.label} id="multi-label">관심 분야</span>
<button
ref={triggerRef}
type="button"
className={styles.trigger}
aria-haspopup="listbox"
aria-expanded={open}
aria-controls="multi-listbox"
aria-labelledby="multi-label multi-trigger"
aria-describedby="multi-hint"
id="multi-trigger"
aria-activedescendant={open ? `multi-opt-${active}` : undefined}
onClick={() => (open ? closeList(true) : openList())}
onKeyDown={onTriggerKeyDown}
>
<span className={styles.chips}>
{chips.length === 0 ? (
<span className={styles.placeholder}>관심 분야 선택</span>
) : (
chips.map((c) => (
<span key={c.value} className={styles.chip}>{c.label}</span>
))
)}
</span>
<span className={styles.chevron + (open ? " " + styles.chevronOpen : "")} aria-hidden="true" />
</button>
{open && (
<ul className={styles.listbox} id="multi-listbox" role="listbox" aria-multiselectable="true" aria-label="관심 분야 목록">
{OPTIONS.map((o, i) => {
const isSelected = selected.indexOf(o.value) > -1;
return (
<li
key={o.value}
id={`multi-opt-${i}`}
role="option"
aria-selected={isSelected}
className={styles.option + (i === active ? " " + styles.optionActive : "")}
onClick={() => {
setActive(i);
toggleOption(i);
}}
onPointerEnter={() => setActive(i)}
>
<span className={styles.check + (isSelected ? " " + styles.checkOn : "")} aria-hidden="true" />
{o.label}
</li>
);
})}
</ul>
)}
<p className={styles.hint} id="multi-hint">화살표로 이동 · Enter로 선택/해제 · Esc로 닫기</p>
</div>
);
}06 · 複数選択コンボボックス — 計測 累積面積 18.629% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
チップは chip-pop のキーフレームで少し行き過ぎてから元の大きさに収まります。
@keyframes chip-pop {
0% { transform: scale(.5); opacity: 0; }
70% { transform: scale(1.06); opacity: 1; }
100% { transform: scale(1); opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.multi__listbox { transition: none !important; }
.multi__trigger, .multi__chevron, .multi__check { transition: none; }
.multi__chip { animation: none; }
}
07メンション・ハッシュタグ強調入力欄
何が見えるか
@名前 や #タグ を打った瞬間、その単語だけに色が付いて残りの文はそのままです。

<main class="stage">
<div class="mh is-demo" id="wrap">
<label class="mh__label" for="mh-input">メッセージ</label>
<div class="mh__field">
<div class="mh__backdrop" id="backdrop" aria-hidden="true"></div>
<textarea class="mh__input" id="mh-input" rows="3" aria-describedby="mh-hint"></textarea>
</div>
<p class="mh__hint" id="mh-hint">@名前 や #タグ を入力してみてください</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-quick; // 포커스 테두리 전환 시간
$easing: $ease-out;
$color: $subject-sky; // 포커스 테두리 · 강조 텍스트 색
// ── 데모 무대 ──────────────────────────────────────────
@include stage($stage-orange);
.mh {
box-sizing: border-box;
width: min(280px, 86vw);
background: $subject-cream;
border-radius: $r-card;
padding: $sp-5 $sp-5 $sp-4;
box-shadow: $shadow-raised;
&__label {
display: block;
margin: 0 0 $sp-2;
font-weight: 700;
font-size: 13px;
line-height: 1;
font-family: inherit;
color: $stage-ink;
}
&__field {
position: relative;
width: 100%;
}
&__backdrop,
&__input {
box-sizing: border-box;
width: 100%;
min-height: 96px;
max-height: 160px;
padding: $sp-3 $sp-4;
border: 2px solid transparent;
border-radius: $r-control;
font-weight: 500;
font-size: clamp(13px, 3.8vw, 15px);
line-height: 1.5;
font-family: inherit;
white-space: pre-wrap;
word-wrap: break-word;
overflow-wrap: break-word;
}
&__backdrop {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
color: $stage-ink;
background: #fff;
mark {
background: none;
color: $color;
font-weight: 700;
font-family: inherit;
}
}
&__input {
position: relative;
display: block;
resize: none;
overflow-y: auto;
color: transparent;
caret-color: $stage-ink;
background: transparent;
transition: border-color $duration $easing;
&:focus { outline: none; border-color: $color; }
}
&__hint {
margin: $sp-2 0 0;
font-size: 11px;
line-height: 1.4;
font-family: inherit;
color: rgba($stage-ink, .72);
}
}
// 320px 세로(무대 174px)에 맞춘다 — 입력칸을 96px 로 두면 카드가 2px 넘친다 (BD9 실측)
@media (max-width: 340px) {
.mh__backdrop,
.mh__input { min-height: 88px; }
}
@media (prefers-reduced-motion: reduce) {
.mh__input { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.mh {
box-sizing: border-box;
width: min(280px, 86vw);
background: #fff7e6;
border-radius: 16px;
padding: 20px 20px 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.mh__label {
display: block;
margin: 0 0 8px;
font-weight: 700;
font-size: 13px;
line-height: 1;
font-family: inherit;
color: #17141a;
}
.mh__field {
position: relative;
width: 100%;
}
.mh__backdrop, .mh__input {
box-sizing: border-box;
width: 100%;
min-height: 96px;
max-height: 160px;
padding: 12px 16px;
border: 2px solid transparent;
border-radius: 12px;
font-weight: 500;
font-size: clamp(13px, 3.8vw, 15px);
line-height: 1.5;
font-family: inherit;
white-space: pre-wrap;
word-wrap: break-word;
overflow-wrap: break-word;
}
.mh__backdrop {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
color: #17141a;
background: #fff;
}
.mh__backdrop mark {
background: none;
color: #6ec8ff;
font-weight: 700;
font-family: inherit;
}
.mh__input {
position: relative;
display: block;
resize: none;
overflow-y: auto;
color: transparent;
caret-color: #17141a;
background: transparent;
transition: border-color 220ms ease-out;
}
.mh__input:focus {
outline: none;
border-color: #6ec8ff;
}
.mh__hint {
margin: 8px 0 0;
font-size: 11px;
line-height: 1.4;
font-family: inherit;
color: rgba(23, 20, 26, 0.72);
}
@media (max-width: 340px) {
.mh__backdrop,
.mh__input {
min-height: 88px;
}
}
@media (prefers-reduced-motion: reduce) {
.mh__input {
transition: none;
}
}(function () {
var wrap = document.getElementById('wrap');
var input = document.getElementById('mh-input');
var backdrop = document.getElementById('backdrop');
function escapeHtml(s) {
return s
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function highlight(s) {
return s.replace(/([@#])([\w가-힣]+)/g, function (m) {
return '<mark>' + m + '</mark>';
});
}
function rebuild() {
var v = input.value;
var html = highlight(escapeHtml(v));
backdrop.innerHTML = html + (v.slice(-1) === '\n' ? ' ' : '');
}
input.addEventListener('input', rebuild);
input.addEventListener('scroll', function () {
backdrop.scrollTop = input.scrollTop;
});
function stopDemo() {
if (!wrap.classList.contains('is-demo')) return;
wrap.classList.remove('is-demo');
clearInterval(timer);
}
input.addEventListener('pointerdown', stopDemo);
input.addEventListener('keydown', stopDemo);
var full = '@デザインチーム 新しい#ボタンスタイルどう?';
var i = 0, paused = false;
var timer = setInterval(function () {
if (paused) return;
if (i > full.length) {
paused = true;
setTimeout(function () { input.value = ''; rebuild(); i = 0; paused = false; }, 900);
return;
}
input.value = full.slice(0, i);
rebuild();
i++;
}, 110);
})();import { useRef, useState, type ChangeEvent } from "react";
import styles from "./MentionHighlightInput.module.scss";
export type MentionHighlightInputProps = {
label?: string;
};
function escapeHtml(s: string): string {
return s
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
function highlight(s: string): string {
const escaped = escapeHtml(s);
return escaped.replace(/([@#])([\w가-힣]+)/g, (m) => `<mark>${m}</mark>`);
}
export default function MentionHighlightInput({ label = "메시지" }: MentionHighlightInputProps) {
const [value, setValue] = useState("");
const backdropRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const onChange = (e: ChangeEvent<HTMLTextAreaElement>) => setValue(e.target.value);
const onScroll = () => {
if (backdropRef.current && inputRef.current) {
backdropRef.current.scrollTop = inputRef.current.scrollTop;
}
};
const html = highlight(value) + (value.slice(-1) === "\n" ? " " : "");
return (
<div className={styles.mh}>
<label className={styles.label} htmlFor="mh-input-react">
{label}
</label>
<div className={styles.field}>
<div
className={styles.backdrop}
aria-hidden="true"
ref={backdropRef}
dangerouslySetInnerHTML={{ __html: html }}
/>
<textarea
id="mh-input-react"
className={styles.input}
ref={inputRef}
rows={3}
value={value}
onChange={onChange}
onScroll={onScroll}
aria-describedby="mh-hint-react"
/>
</div>
<p className={styles.hint} id="mh-hint-react">
@이름 #태그를 입력해 보세요
</p>
</div>
);
}07 · メンション・ハッシュタグ強調入力欄 — 計測 累積面積 1.192% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
contenteditable は使わず、文字を隠した textarea の下に同じ幅と同じ折り返し規則の backdrop を重ねて、カーソルがあるべき場所に留まるようにしています。
&__backdrop,
&__input {
box-sizing: border-box;
width: 100%;
min-height: 96px;
max-height: 160px;
padding: $sp-3 $sp-4;
border: 2px solid transparent;
border-radius: $r-control;
font-weight: 500;
font-size: clamp(13px, 3.8vw, 15px);
line-height: 1.5;
font-family: inherit;
white-space: pre-wrap;
word-wrap: break-word;
overflow-wrap: break-word;
}
08文字数カウンター入力欄
画面で起きること
右下の数字が 0/80 から増えていき、残りが12文字以下になると数字と枠が赤くなります。

<main class="stage">
<div class="cc is-demo" id="wrap">
<label class="cc__label" for="cc-input">自己紹介</label>
<textarea class="cc__area" id="cc-input" maxlength="80" rows="3" placeholder="自分を一言で紹介してください" aria-describedby="cc-count"></textarea>
<p class="cc__count" id="cc-count" aria-live="polite">0/80</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-quick; // 경고 색 전환 시간
$easing: $ease-out;
$color: $subject-blue-deep; // 평상시 카운터 · 포커스 테두리 색 (크림 면 위 하늘색 글자는 1.73:1)
// ── 데모 무대 ──────────────────────────────────────────
@include stage($stage-paper);
.cc {
box-sizing: border-box;
width: min(280px, 86vw);
background: $subject-cream;
border-radius: $r-card;
padding: $sp-5 $sp-5 $sp-4;
box-shadow: $shadow-raised;
&__label {
display: block;
margin: 0 0 $sp-2;
font-weight: 700;
font-size: 13px;
line-height: 1;
font-family: inherit;
color: $stage-ink;
}
&__area {
box-sizing: border-box;
display: block;
width: 100%;
min-height: 96px;
max-height: 160px;
resize: none;
overflow-y: auto;
border: 2px solid transparent;
border-radius: $r-control;
padding: $sp-3 $sp-4;
font-weight: 500;
font-size: clamp(13px, 3.8vw, 15px);
line-height: 1.5;
font-family: inherit;
color: $stage-ink;
background: #fff;
transition: border-color $duration $easing;
&:focus { outline: none; border-color: $color; }
&.is-warn { border-color: $error; }
}
&__count {
margin: $sp-2 0 0;
text-align: right;
font-size: 12px;
font-weight: 600;
font-family: inherit;
color: $color;
transition: color $duration $easing;
&.is-warn { color: $error; }
}
}
// 320px 세로(무대 174px)에 맞춘다 — 입력칸을 96px 로 두면 카드가 3px 넘친다 (BD9 실측)
@media (max-width: 340px) {
.cc__area { min-height: 88px; }
}
@media (prefers-reduced-motion: reduce) {
.cc__area, .cc__count { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cc {
box-sizing: border-box;
width: min(280px, 86vw);
background: #fff7e6;
border-radius: 16px;
padding: 20px 20px 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.cc__label {
display: block;
margin: 0 0 8px;
font-weight: 700;
font-size: 13px;
line-height: 1;
font-family: inherit;
color: #17141a;
}
.cc__area {
box-sizing: border-box;
display: block;
width: 100%;
min-height: 96px;
max-height: 160px;
resize: none;
overflow-y: auto;
border: 2px solid transparent;
border-radius: 12px;
padding: 12px 16px;
font-weight: 500;
font-size: clamp(13px, 3.8vw, 15px);
line-height: 1.5;
font-family: inherit;
color: #17141a;
background: #fff;
transition: border-color 220ms ease-out;
}
.cc__area:focus {
outline: none;
border-color: #0a4fe8;
}
.cc__area.is-warn {
border-color: #c2361a;
}
.cc__count {
margin: 8px 0 0;
text-align: right;
font-size: 12px;
font-weight: 600;
font-family: inherit;
color: #0a4fe8;
transition: color 220ms ease-out;
}
.cc__count.is-warn {
color: #c2361a;
}
@media (max-width: 340px) {
.cc__area {
min-height: 88px;
}
}
@media (prefers-reduced-motion: reduce) {
.cc__area, .cc__count {
transition: none;
}
}(function () {
var wrap = document.getElementById('wrap');
var area = document.getElementById('cc-input');
var count = document.getElementById('cc-count');
var MAX = 80;
var WARN = 12;
function update() {
var len = area.value.length;
var remaining = MAX - len;
count.textContent = len + '/' + MAX;
if (remaining <= WARN) {
count.classList.add('is-warn');
area.classList.add('is-warn');
} else {
count.classList.remove('is-warn');
area.classList.remove('is-warn');
}
}
area.addEventListener('input', update);
function stopDemo() {
if (!wrap.classList.contains('is-demo')) return;
wrap.classList.remove('is-demo');
clearInterval(timer);
}
area.addEventListener('pointerdown', stopDemo);
area.addEventListener('keydown', stopDemo);
var full = 'こんにちは!毎日少しずつ成長するのが好きなフロントエンド開発者です。コーヒーと猫が好きで、旅行も楽しんでいます。よろしくお願いします!';
var i = 0, paused = false;
var timer = setInterval(function () {
if (paused) return;
if (i > full.length) {
paused = true;
setTimeout(function () { area.value = ''; update(); i = 0; paused = false; }, 900);
return;
}
area.value = full.slice(0, i);
update();
i++;
}, 90);
update();
})();import { useState, type ChangeEvent } from "react";
import styles from "./CharacterCounterInput.module.scss";
export type CharacterCounterInputProps = {
label?: string;
max?: number;
};
const WARN_THRESHOLD = 12;
export default function CharacterCounterInput({ label = "자기소개", max = 80 }: CharacterCounterInputProps) {
const [value, setValue] = useState("");
const remaining = max - value.length;
const isWarn = remaining <= WARN_THRESHOLD;
const onChange = (e: ChangeEvent<HTMLTextAreaElement>) => setValue(e.target.value);
return (
<div className={styles.cc}>
<label className={styles.label} htmlFor="cc-input-react">
{label}
</label>
<textarea
id="cc-input-react"
className={isWarn ? `${styles.area} ${styles.warn}` : styles.area}
maxLength={max}
rows={3}
placeholder="나를 한 줄로 소개해 보세요"
value={value}
onChange={onChange}
aria-describedby="cc-count-react"
/>
<p
className={isWarn ? `${styles.count} ${styles.warn}` : styles.count}
id="cc-count-react"
aria-live="polite"
>
{value.length}/{max}
</p>
</div>
);
}08 · 文字数カウンター入力欄 — 計測 累積面積 2.316% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
上限そのものは HTML の maxlength="80" が守り、数字には aria-live="polite" が付いているため、画面を見ていない人にも今の文字数が読み上げられます。
function update() {
var len = area.value.length;
var remaining = MAX - len;
count.textContent = len + '/' + MAX;
if (remaining <= WARN) {
count.classList.add('is-warn');
area.classList.add('is-warn');
} else {
count.classList.remove('is-warn');
area.classList.remove('is-warn');
}
}
area.addEventListener('input', update);
09クリア(×)ボタン付き入力欄
どんな動きか
1文字でも打つと右に丸い×ボタンが現れ、押すと欄が空になってフォーカスはその欄に残ります。

<main class="stage">
<div class="cb is-demo" id="wrap">
<label class="cb__label" for="cb-input">検索</label>
<div class="cb__field">
<input class="cb__input" id="cb-input" type="text" placeholder="検索キーワードを入力" autocomplete="off">
<button type="button" class="cb__clear" id="cb-clear" aria-label="クリア">
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true" focusable="false">
<line x1="3" y1="3" x2="13" y2="13" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<line x1="13" y1="3" x2="3" y2="13" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-instant; // X 버튼 팝인 시간
$easing: $ease-pop;
$color: $subject-blue; // 포커스 테두리 색
// ── 데모 무대 ──────────────────────────────────────────
@include stage($stage-ink);
.cb {
box-sizing: border-box;
width: min(272px, 84vw);
background: $subject-cream;
border-radius: $r-card;
padding: $sp-4;
box-shadow: $shadow-raised;
&__label {
display: block;
margin: 0 0 $sp-1;
font-weight: 700;
font-size: 12px;
line-height: 1;
font-family: inherit;
color: $stage-ink;
}
&__field {
position: relative;
width: 100%;
}
&__input {
box-sizing: border-box;
width: 100%;
border: 2px solid transparent;
border-radius: $r-control;
padding: $sp-3 $sp-10 $sp-3 $sp-4;
font-weight: 500;
font-size: clamp(14px, 4.2vw, 16px);
font-family: inherit;
color: $stage-ink;
background: #fff;
transition: border-color $duration $easing;
&:focus { outline: none; border-color: $color; }
}
&__clear {
position: absolute;
top: 50%;
right: $sp-2;
transform: translateY(-50%) scale(.5);
width: 24px;
height: 24px;
display: grid;
place-items: center;
border: 0;
border-radius: 50%;
background: rgba(23, 20, 26, .08);
color: $stage-ink;
cursor: pointer;
opacity: 0;
pointer-events: none;
transition: opacity $duration $easing, transform $duration $easing, background $duration $easing;
&:hover, &:focus-visible { background: rgba(23, 20, 26, .16); }
}
}
.cb.has-value .cb__clear {
opacity: 1;
pointer-events: auto;
transform: translateY(-50%) scale(1);
}
@media (prefers-reduced-motion: reduce) {
.cb__input, .cb__clear { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cb {
box-sizing: border-box;
width: min(272px, 84vw);
background: #fff7e6;
border-radius: 16px;
padding: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.cb__label {
display: block;
margin: 0 0 4px;
font-weight: 700;
font-size: 12px;
line-height: 1;
font-family: inherit;
color: #17141a;
}
.cb__field {
position: relative;
width: 100%;
}
.cb__input {
box-sizing: border-box;
width: 100%;
border: 2px solid transparent;
border-radius: 12px;
padding: 12px 40px 12px 16px;
font-weight: 500;
font-size: clamp(14px, 4.2vw, 16px);
font-family: inherit;
color: #17141a;
background: #fff;
transition: border-color 140ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.cb__input:focus {
outline: none;
border-color: #2f6df6;
}
.cb__clear {
position: absolute;
top: 50%;
right: 8px;
transform: translateY(-50%) scale(0.5);
width: 24px;
height: 24px;
display: grid;
place-items: center;
border: 0;
border-radius: 50%;
background: rgba(23, 20, 26, 0.08);
color: #17141a;
cursor: pointer;
opacity: 0;
pointer-events: none;
transition: opacity 140ms cubic-bezier(0.2, 1.4, 0.4, 1), transform 140ms cubic-bezier(0.2, 1.4, 0.4, 1), background 140ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.cb__clear:hover, .cb__clear:focus-visible {
background: rgba(23, 20, 26, 0.16);
}
.cb.has-value .cb__clear {
opacity: 1;
pointer-events: auto;
transform: translateY(-50%) scale(1);
}
@media (prefers-reduced-motion: reduce) {
.cb__input, .cb__clear {
transition: none;
}
}(function () {
var wrap = document.getElementById('wrap');
var input = document.getElementById('cb-input');
var clearBtn = document.getElementById('cb-clear');
function sync() {
if (input.value) {
wrap.classList.add('has-value');
} else {
wrap.classList.remove('has-value');
}
}
function doClear() {
input.value = '';
sync();
input.focus({ preventScroll: true });
}
input.addEventListener('input', sync);
clearBtn.addEventListener('click', function () { stopDemo(); doClear(); });
input.addEventListener('keydown', function (e) {
stopDemo();
if (e.key === 'Escape' && input.value) {
doClear();
}
});
input.addEventListener('pointerdown', stopDemo);
function stopDemo() {
if (!wrap.classList.contains('is-demo')) return;
wrap.classList.remove('is-demo');
clearInterval(timer);
}
var full = 'サマードレス';
var i = 0, paused = false;
var timer = setInterval(function () {
if (paused) return;
if (i > full.length) {
paused = true;
setTimeout(function () { doClear(); i = 0; paused = false; }, 900);
return;
}
input.value = full.slice(0, i);
sync();
i++;
}, 130);
})();import { useRef, useState, type ChangeEvent } from "react";
import styles from "./ClearButtonInput.module.scss";
export type ClearButtonInputProps = {
label?: string;
};
export default function ClearButtonInput({ label = "검색" }: ClearButtonInputProps) {
const [value, setValue] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const clear = () => {
setValue("");
inputRef.current?.focus({ preventScroll: true });
};
const onChange = (e: ChangeEvent<HTMLInputElement>) => setValue(e.target.value);
const onKeyDown = (e: any) => {
if (e.key === "Escape" && value) {
clear();
}
};
return (
<div className={styles.cb}>
<label className={styles.label} htmlFor="cb-input-react">
{label}
</label>
<div className={styles.field}>
<input
id="cb-input-react"
ref={inputRef}
className={styles.input}
type="text"
placeholder="검색어를 입력하세요"
autoComplete="off"
value={value}
onChange={onChange}
onKeyDown={onKeyDown}
/>
<button
type="button"
className={value ? `${styles.clear} ${styles.visible}` : styles.clear}
aria-label="지우기"
onClick={clear}
>
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true" focusable="false">
<line x1="3" y1="3" x2="13" y2="13" stroke="currentColor" strokeWidth={2} strokeLinecap="round" />
<line x1="13" y1="3" x2="3" y2="13" stroke="currentColor" strokeWidth={2} strokeLinecap="round" />
</svg>
</button>
</div>
</div>
);
}09 · クリア(×)ボタン付き入力欄 — 計測 累積面積 2.114% · 動いたフレーム 11/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
ボタンは値がない間は opacity: 0 と pointer-events: none で完全に退避していて、中身ができた瞬間だけ戻ってきます。
&__clear {
position: absolute;
top: 50%;
right: $sp-2;
transform: translateY(-50%) scale(.5);
width: 24px;
height: 24px;
display: grid;
place-items: center;
border: 0;
border-radius: 50%;
background: rgba(23, 20, 26, .08);
color: $stage-ink;
cursor: pointer;
opacity: 0;
pointer-events: none;
transition: opacity $duration $easing, transform $duration $easing, background $duration $easing;
&:hover, &:focus-visible { background: rgba(23, 20, 26, .16); }
}
どこで壊れるか — 落とし穴
壊れ方は縦方向で、幅 320px で現れました。07 と 08 はどちらも3行の textarea を中に持っていて、入力欄の最小の高さを 96px のままにするとカード全体がその時点のステージより 07 は 2px、08 は 3px 高くなり、下の枠が切れていました。480px のデモサイズでは余白が問題を隠していたため、広い画面を目で確認しただけではこの欠陥は見つかりませんでした。直し方は入力欄を流れから外すことではなく、画面の幅が 340px 以下に下がった瞬間から min-height を 96px から 88px へ下げる @media (max-width: 340px) ブロックを足すことでした。ラベルとヒントの文字はそのままに、入力欄の縦だけ 8px 縮めると、両方の項目からはみ出し判定が消えました。一覧が開く04·05·06をドロップダウンの道で解いた形はセレクトボックス デザイン css 9選が別に扱います。この規則を入れたソースは、解凍パスワード ttw779pg で開く zip の中に9つ分そろえて入っています。
アクセシビリティ
9つとも prefers-reduced-motion: reduce では transition を止めても状態はそのまま残します。枠とアイコンの色は切り替わりの動きなしで即座に変わり、浮いていたラベルは浮いた位置に立ったまま、06 のチップは animation: none で跳ねずにそのまま現れます。一覧が開く3つ(04·05·06)は aria-expanded で開閉を伝え、aria-activedescendant でフォーカスを移さずに指している行だけを変え、08 の文字数は aria-live="polite" で読み上げられます。フォーカスを戻すときに必ず focus({ preventScroll: true }) を使うのは、このデモが記事の中に iframe で埋め込まれているためで、素の focus() だと読んでいる人の画面が下へ引っ張られるからです。ここで使った入力属性の意味は MDN の input 要素の解説にまとまっています。
同じ場所で使える部品はフォーム・入力カテゴリに、入力が間違っていたときに震える反応はエラーカテゴリにまとめてあります。
FAQ
カレンダーが開く生年月日欄があるのに、なぜ自分で作るのですか
ブラウザのカレンダーは信用できますが、見た目がブラウザごとに違いますし、速く打つ人にとっては、開いて閉じる時間の分だけ入力がむしろ遅くなります。この欄は数字だけを受け付けて 2026/09/11 の形に組み立てるので、カレンダーなしで書式が崩れません。カレンダーが欲しい画面では両方を並べます。
フォーム デザイン ui として9つともマウスなしで使えますか
使えます。01·02·03·07·08 は普通の input と textarea なので、ブラウザが無料でやってくれることをそのまま使います。一覧が開く 04·05·06 は矢印で移動し、Enter で確定し、Escape で閉じます。04 と 06 は矢印が両端で折り返し、Tab で離れると一覧が閉まります。09 は入力欄で Escape を押すと値が消えます。この9つの欄が複数の段階に分かれるとフォーム ステップ ui 9選の画面構成になります。
React 版で上限や色を変えられますか
08 の文字数の上限は max という props で変えられて、初期値は 80 です。警告が始まる残り12文字はモジュール内の定数なので、変えるにはその行を書き換えます。色と速さは、バニラ版では style.scss の一番上にある $duration·$easing·$color の3行を、React 版では各 module.scss に直接書かれた値を変えます。









