05住所オートコンプリート風入力欄
住所を入力している間に下に候補リストが表示され、矢印キーとEnterで選んで入力欄を埋められます。
使いどころ配送先住所の入力・会員登録の住所欄
role listbox화살표 키 탐색정적 후보 목록

<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 に同梱 · 会員登録 フォーム デザイン 入力欄9選 — キーボード操作対応
このデモが属する記事
会員登録 フォーム デザイン 入力欄9選 — キーボード操作対応会員登録 フォーム デザインの良し悪しは送信ボタンではなく入力欄で決まります。ここに並べた9つは、登録画面が実際に求める入力欄で、メールアドレス欄からクリアボタンまで、コードを貼ればそのまま動きます。 この記事のデモをすべて見る


