06검색 콤보박스 리스트박스
입력창에 글자를 치면 그 글자로 시작하는 항목만 걸러 목록이 열리고, 방향키로 옮긴 항목에 aria-selected 가 true 로 바뀌며 is-active 클래스가 함께 붙는다.
쓰임국가·도시 검색 선택창, 수신자 검색 후 선택
role=comboboxaria-selected0.18초

<main class="stage">
<div class="cb is-demo">
<label class="sr-only" for="cbInput">국가 검색</label>
<input id="cbInput" class="cb__input" type="text" role="combobox" aria-expanded="false"
aria-controls="cbList" aria-autocomplete="list" autocomplete="off" placeholder="국가 검색">
<ul class="cb__list" id="cbList" role="listbox" aria-label="국가 목록"></ul>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-blue;
@include stage($stage-ink);
.sr-only {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
}
.cb { position: relative; width: min(240px, 76vw); }
.cb__input {
width: 100%; box-sizing: border-box; font: inherit; font-family: inherit; font-size: 14px;
padding: $sp-3 $sp-4; border-radius: $r-control; border: 1px solid rgba($subject-cream, .22);
background: rgba($subject-cream, .06); color: $subject-cream; outline: none;
transition: border-color $duration $easing;
}
.cb.is-open .cb__input, .cb__input:focus { border-color: $color; }
.cb__list {
position: absolute; left: 0; right: 0; top: calc(100% + #{$sp-1}); margin: 0; padding: 0 $sp-1;
list-style: none; border-radius: $r-control; background: $stage-ink;
border: 1px solid rgba($subject-cream, .16); box-shadow: $shadow-raised;
opacity: 0; max-height: 0; overflow: hidden; transform: translateY(-6px); pointer-events: none;
transition: opacity $duration $easing, transform $duration $easing, max-height $duration $easing;
}
.cb.is-open .cb__list { opacity: 1; max-height: 132px; padding: $sp-1; transform: translateY(0); pointer-events: auto; }
.cb__opt {
padding: $sp-2 $sp-3; border-radius: $r-sm; font-size: 13px; color: $subject-cream; cursor: pointer;
transition: background-color $duration $easing;
}
.cb__opt.is-active, .cb__opt:hover { background: rgba($color, .3); }
@media (prefers-reduced-motion: reduce) {
.cb__list, .cb__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);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.cb {
position: relative;
width: min(240px, 76vw);
}
.cb__input {
width: 100%;
box-sizing: border-box;
font: inherit;
font-family: inherit;
font-size: 14px;
padding: 12px 16px;
border-radius: 12px;
border: 1px solid rgba(255, 247, 230, 0.22);
background: rgba(255, 247, 230, 0.06);
color: #fff7e6;
outline: none;
transition: border-color 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.cb.is-open .cb__input, .cb__input:focus {
border-color: #2f6df6;
}
.cb__list {
position: absolute;
left: 0;
right: 0;
top: calc(100% + 4px);
margin: 0;
padding: 0 4px;
list-style: none;
border-radius: 12px;
background: #17141a;
border: 1px solid rgba(255, 247, 230, 0.16);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
opacity: 0;
max-height: 0;
overflow: hidden;
transform: translateY(-6px);
pointer-events: none;
transition: opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1), transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1), max-height 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.cb.is-open .cb__list {
opacity: 1;
max-height: 132px;
padding: 4px;
transform: translateY(0);
pointer-events: auto;
}
.cb__opt {
padding: 8px 12px;
border-radius: 8px;
font-size: 13px;
color: #fff7e6;
cursor: pointer;
transition: background-color 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.cb__opt.is-active, .cb__opt:hover {
background: rgba(47, 109, 246, 0.3);
}
@media (prefers-reduced-motion: reduce) {
.cb__list, .cb__input {
transition: none;
}
}(function () {
var cb = document.querySelector('.cb'), input = document.getElementById('cbInput'), list = document.getElementById('cbList');
var all = ['대한민국', '일본', '미국', '캐나다', '프랑스', '독일', '베트남', '태국', '호주', '영국'];
var active = -1, shown = [];
function render(q) {
shown = all.filter(function (c) { return c.indexOf(q) === 0 || !q; }).slice(0, 3);
list.innerHTML = '';
shown.forEach(function (c, i) {
var li = document.createElement('li');
li.id = 'opt-' + i; li.className = 'cb__opt'; li.setAttribute('role', 'option'); li.textContent = c;
li.setAttribute('aria-selected', i === active ? 'true' : 'false');
if (i === active) li.classList.add('is-active');
li.addEventListener('click', function () { choose(i); });
list.appendChild(li);
});
}
function open() { cb.classList.add('is-open'); input.setAttribute('aria-expanded', 'true'); }
function close() { cb.classList.remove('is-open'); input.setAttribute('aria-expanded', 'false'); active = -1; }
function choose(i) { input.value = shown[i]; close(); }
input.addEventListener('input', function () { stop(); active = 0; render(input.value); open(); });
input.addEventListener('focus', function () { stop(); render(input.value); open(); });
input.addEventListener('keydown', function (e) {
stop();
if (e.key === 'ArrowDown') { e.preventDefault(); active = Math.min(shown.length - 1, active + 1); render(input.value); open(); }
else if (e.key === 'ArrowUp') { e.preventDefault(); active = Math.max(0, active - 1); render(input.value); }
else if (e.key === 'Enter' && active >= 0) { e.preventDefault(); choose(active); }
else if (e.key === 'Escape') { close(); }
});
document.addEventListener('pointerdown', function (e) { if (!cb.contains(e.target)) close(); });
var demoQ = ['프', '프랑', '캐나다', ''], qi = 0, phase = 0;
var t = setInterval(function () {
phase = (phase + 1) % 2;
if (phase === 1) { active = 0; render(demoQ[qi % demoQ.length]); open(); }
else { close(); qi++; }
}, 650);
function stop() { cb.classList.remove('is-demo'); clearInterval(t); }
render('');
}());import { useState, useMemo } from "react";
import type { CSSProperties } from "react";
import styles from "./ComboboxSearchSelectListbox.module.scss";
export type ComboboxSearchSelectListboxProps = {
options?: string[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--c"?: string };
const DEFAULT_OPTIONS = ["South Korea", "Japan", "United States", "Canada", "France", "Germany", "Vietnam", "Thailand", "Australia", "United Kingdom"];
export default function ComboboxSearchSelectListbox({
options = DEFAULT_OPTIONS,
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#2f6df6",
}: ComboboxSearchSelectListboxProps) {
const [query, setQuery] = useState("");
const [open, setOpen] = useState(false);
const [active, setActive] = useState(0);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--c": color };
const shown = useMemo(
() => options.filter((c) => !query || c.toLowerCase().startsWith(query.toLowerCase())).slice(0, 4),
[options, query]
);
function choose(i: number) {
setQuery(shown[i]); setOpen(false);
}
return (
<div className={styles.cb} style={vars} data-open={open}>
<label className={styles.srOnly} htmlFor="cb-country-input">Search country</label>
<input
id="cb-country-input"
className={styles.input}
type="text"
role="combobox"
aria-expanded={open}
aria-controls="cb-country-list"
aria-autocomplete="list"
autoComplete="off"
placeholder="Search country"
value={query}
onChange={(e: any) => { setQuery(e.target.value); setActive(0); setOpen(true); }}
onFocus={() => setOpen(true)}
onKeyDown={(e: any) => {
if (e.key === "ArrowDown") { e.preventDefault(); setActive((a) => Math.min(shown.length - 1, a + 1)); setOpen(true); }
else if (e.key === "ArrowUp") { e.preventDefault(); setActive((a) => Math.max(0, a - 1)); }
else if (e.key === "Enter" && shown[active]) { e.preventDefault(); choose(active); }
else if (e.key === "Escape") setOpen(false);
}}
onBlur={() => setTimeout(() => setOpen(false), 100)}
/>
<ul className={styles.list} id="cb-country-list" role="listbox" aria-label="Country list">
{shown.map((c, i) => (
<li key={c} role="option" aria-selected={i === active} data-active={i === active}
className={styles.opt} onMouseDown={() => choose(i)}>
{c}
</li>
))}
</ul>
</div>
);
}06 검색 콤보박스 리스트박스 — 측정 누적면적 7.848% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 달력 선택 UI 9종 — 복붙하면 되는
이 데모가 속한 글
달력 선택 UI 9종 — 복붙하면 되는달력 선택 UI는 시작일과 종료일처럼 값 하나가 아니라 범위·묶음을 고르게 해주는 선택 도구입니다. 세그먼트 컨트롤이나 색상 스와치처럼 이미 다뤘던 낱개 선택지 말고, 날짜 범위·가격대·아바타·사이즈+수량·검색 드롭 이 글의 데모 전부 보기

