03Searchable combobox
Typing really shrinks the list, highlights only the matched characters, and shows an empty-state line when nothing is left.
Best forWhen the list passes twenty options and scrolling to find one (cities, teams, tags) stops working
role=comboboxinputmark

<main class="stage">
<div class="cb is-demo">
<span class="cb__label" id="cb-label">City</span>
<div class="cb__field">
<span class="cb__mag" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2"/><path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72"/></g></svg></span>
<input class="cb__input" id="cb-input" type="text" role="combobox" aria-expanded="true" aria-controls="cb-list" aria-autocomplete="list" aria-labelledby="cb-label" placeholder="Search city" autocomplete="off">
<span class="cb__typed" aria-hidden="true">Dae</span>
</div>
<div class="cb__box">
<ul class="cb__list" id="cb-list" role="listbox" aria-labelledby="cb-label">
<li class="cb__opt cb__opt--out" id="cb-o1" role="option" aria-selected="false">Seoul</li>
<li class="cb__opt cb__opt--out" id="cb-o2" role="option" aria-selected="false">Busan</li>
<li class="cb__opt cb__opt--hit" id="cb-o3" role="option" aria-selected="false"><span class="cb__m">Dae</span>gu</li>
<li class="cb__opt cb__opt--hit" id="cb-o4" role="option" aria-selected="false"><span class="cb__m">Dae</span>jeon</li>
</ul>
<p class="cb__empty" id="cb-empty" hidden>No results</p>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-orange);
.cb {
box-sizing: border-box;
width: min(224px, 84vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $stage-ink;
}
.cb__label {
display: block;
font-size: 10px; line-height: 12px; font-weight: 800;
letter-spacing: .04em;
color: $subject-ink;
margin: 0 0 $sp-1;
}
.cb__field {
position: relative;
box-sizing: border-box;
height: 32px;
display: flex; align-items: center; gap: $sp-2;
padding: 0 $sp-3;
background: #fff;
border-radius: $r-control;
box-shadow: $shadow-press;
}
.cb__mag { display: inline-flex; color: rgba(23, 20, 26, .4); }
.cb__input {
flex: 1 1 auto;
min-width: 0;
border: 0; background: transparent;
font-family: inherit;
font-size: 12px; font-weight: 600;
color: $stage-ink;
}
.cb__input:focus-visible { outline: 2px solid $color; outline-offset: 4px; border-radius: $r-xs; }
.cb__typed {
position: absolute;
left: 32px; right: 12px; top: 50%;
display: flex; align-items: center;
height: 18px;
transform: translateY(-50%);
background: #fff;
font-size: 12px; font-weight: 700;
color: $stage-ink;
opacity: 0;
pointer-events: none;
}
.cb__box {
position: relative;
box-sizing: border-box;
height: 104px;
margin: $sp-2 0 0;
padding: $sp-1;
overflow: hidden;
background: #fff;
border-radius: $r-card;
box-shadow: $shadow-press;
}
.cb__list { margin: 0; padding: 0; list-style: none; }
.cb__opt {
box-sizing: border-box;
height: 24px;
display: flex; align-items: center;
padding: 0 $sp-2;
border-radius: $r-sm;
font-size: 12px; font-weight: 600;
color: rgba(23, 20, 26, .78);
cursor: pointer;
}
.cb__opt.is-at { background: rgba(47, 109, 246, .12); color: $color; }
.cb__m { position: relative; z-index: 0; font-weight: 800; }
.cb__m::before {
content: "";
position: absolute;
inset: -2px -2px;
z-index: -1;
border-radius: $r-xs;
background: rgba(255, 201, 77, .8);
opacity: 0;
}
.cb__m.is-on::before { opacity: 1; }
.cb__empty {
margin: 0; padding: $sp-2;
font-size: 11px; font-weight: 600;
color: rgba(23, 20, 26, .4);
}
// ── 자동 루프: 「대」를 치면 안 맞는 두 줄이 사라지고 맞은 두 줄이 올라온다. 다시 원래대로 되돌아온다 ──
// 사라지는 줄과 올라오는 줄이 같은 자리를 지나므로, 사라짐은 steps(1) 로 먼저 끝내고
// 그다음 남은 줄이 미끄러져 올라온다 — 겹쳐 비치는 프레임이 없다
.cb.is-demo .cb__typed,
.cb.is-demo .cb__m::before { animation: cb-cut-in $dur-loop steps(1, end) infinite; }
.cb.is-demo .cb__opt--out { animation: cb-cut-out $dur-loop steps(1, end) infinite; }
.cb.is-demo .cb__opt--hit { animation: cb-rise $dur-loop $easing infinite; }
@keyframes cb-cut-in {
0%, 17% { opacity: 0; }
18%, 83% { opacity: 1; }
84%, 100% { opacity: 0; }
}
@keyframes cb-cut-out {
0%, 17% { opacity: 1; }
18%, 83% { opacity: 0; }
84%, 100% { opacity: 1; }
}
@keyframes cb-rise {
0%, 18% { transform: translateY(0); }
40%, 62% { transform: translateY(-48px); }
84%, 100% { transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
.cb.is-demo .cb__typed,
.cb.is-demo .cb__opt--out,
.cb.is-demo .cb__opt--hit,
.cb.is-demo .cb__m::before { animation: none !important; }
.cb__typed { opacity: 0; }
}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);
}
.cb {
box-sizing: border-box;
width: min(224px, 84vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
}
.cb__label {
display: block;
font-size: 10px;
line-height: 12px;
font-weight: 800;
letter-spacing: 0.04em;
color: #17141a;
margin: 0 0 4px;
}
.cb__field {
position: relative;
box-sizing: border-box;
height: 32px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 12px;
background: #fff;
border-radius: 12px;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.cb__mag {
display: inline-flex;
color: rgba(23, 20, 26, 0.4);
}
.cb__input {
flex: 1 1 auto;
min-width: 0;
border: 0;
background: transparent;
font-family: inherit;
font-size: 12px;
font-weight: 600;
color: #17141a;
}
.cb__input:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 4px;
border-radius: 4px;
}
.cb__typed {
position: absolute;
left: 32px;
right: 12px;
top: 50%;
display: flex;
align-items: center;
height: 18px;
transform: translateY(-50%);
background: #fff;
font-size: 12px;
font-weight: 700;
color: #17141a;
opacity: 0;
pointer-events: none;
}
.cb__box {
position: relative;
box-sizing: border-box;
height: 104px;
margin: 8px 0 0;
padding: 4px;
overflow: hidden;
background: #fff;
border-radius: 16px;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.cb__list {
margin: 0;
padding: 0;
list-style: none;
}
.cb__opt {
box-sizing: border-box;
height: 24px;
display: flex;
align-items: center;
padding: 0 8px;
border-radius: 8px;
font-size: 12px;
font-weight: 600;
color: rgba(23, 20, 26, 0.78);
cursor: pointer;
}
.cb__opt.is-at {
background: rgba(47, 109, 246, 0.12);
color: #2f6df6;
}
.cb__m {
position: relative;
z-index: 0;
font-weight: 800;
}
.cb__m::before {
content: "";
position: absolute;
inset: -2px -2px;
z-index: -1;
border-radius: 4px;
background: rgba(255, 201, 77, 0.8);
opacity: 0;
}
.cb__m.is-on::before {
opacity: 1;
}
.cb__empty {
margin: 0;
padding: 8px;
font-size: 11px;
font-weight: 600;
color: rgba(23, 20, 26, 0.4);
}
.cb.is-demo .cb__typed,
.cb.is-demo .cb__m::before {
animation: cb-cut-in 2s steps(1, end) infinite;
}
.cb.is-demo .cb__opt--out {
animation: cb-cut-out 2s steps(1, end) infinite;
}
.cb.is-demo .cb__opt--hit {
animation: cb-rise 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes cb-cut-in {
0%, 17% {
opacity: 0;
}
18%, 83% {
opacity: 1;
}
84%, 100% {
opacity: 0;
}
}
@keyframes cb-cut-out {
0%, 17% {
opacity: 1;
}
18%, 83% {
opacity: 0;
}
84%, 100% {
opacity: 1;
}
}
@keyframes cb-rise {
0%, 18% {
transform: translateY(0);
}
40%, 62% {
transform: translateY(-48px);
}
84%, 100% {
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.cb.is-demo .cb__typed,
.cb.is-demo .cb__opt--out,
.cb.is-demo .cb__opt--hit,
.cb.is-demo .cb__m::before {
animation: none !important;
}
.cb__typed {
opacity: 0;
}
}// 실제 동작: 글자를 치면 목록이 실제로 줄고, 맞은 글자만 형광으로 칠해진다.
var root = document.querySelector('.cb');
var input = document.getElementById('cb-input');
var list = document.getElementById('cb-list');
var empty = document.getElementById('cb-empty');
var opts = [].slice.call(list.querySelectorAll('.cb__opt'));
var names = opts.map(function (o) { return o.textContent; });
var at = -1;
function render(q) {
root.classList.remove('is-demo');
var shown = 0;
opts.forEach(function (o, i) {
var hit = !q || names[i].indexOf(q) >= 0;
o.hidden = !hit;
if (hit) {
shown++;
var k = names[i].indexOf(q);
o.innerHTML = !q ? names[i]
: names[i].slice(0, k) + '<span class="cb__m is-on">' + q + '</span>' + names[i].slice(k + q.length);
}
});
empty.hidden = shown > 0;
at = -1;
}
input.addEventListener('input', function () { render(input.value.trim()); });
input.addEventListener('keydown', function (e) {
var vis = opts.filter(function (o) { return !o.hidden; });
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
at = Math.max(0, Math.min(at + (e.key === 'ArrowDown' ? 1 : -1), vis.length - 1));
vis.forEach(function (o, i) { o.classList.toggle('is-at', i === at); });
if (vis[at]) input.setAttribute('aria-activedescendant', vis[at].id);
e.preventDefault();
} else if (e.key === 'Enter' && vis[at]) {
vis.forEach(function (o) { o.setAttribute('aria-selected', String(o === vis[at])); });
input.value = vis[at].textContent;
render('');
e.preventDefault();
} else if (e.key === 'Escape') {
input.value = ''; render(''); input.focus({ preventScroll: true });
}
});import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SearchCombobox.module.scss";
export type SearchComboboxProps = {
label?: string;
placeholder?: string;
emptyText?: string;
options?: string[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; preventDefault: () => void };
type ChangeEvt = { target: { value: string } };
type ClickEvt = { stopPropagation: () => void };
type Slice = { before: string; hit: string; after: string };
function split(name: string, q: string): Slice {
if (!q) return { before: name, hit: "", after: "" };
const k = name.toLowerCase().indexOf(q.toLowerCase());
if (k < 0) return { before: name, hit: "", after: "" };
return { before: name.slice(0, k), hit: name.slice(k, k + q.length), after: name.slice(k + q.length) };
}
export default function SearchCombobox({
label = "City",
placeholder = "Search city",
emptyText = "No results",
options = ["Seoul", "Busan", "Daegu", "Daejeon"],
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: SearchComboboxProps) {
const [query, setQuery] = useState("");
const [filter, setFilter] = useState("");
const [picked, setPicked] = useState<string | null>(null);
const [at, setAt] = useState(-1);
const inputRef = useRef<HTMLInputElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const visible = options
.map((name, i) => ({ name, i }))
.filter(({ name }) => !filter || name.toLowerCase().includes(filter.toLowerCase()));
function choose(name: string) {
setPicked(name);
setQuery(name);
setFilter("");
setAt(-1);
}
return (
<div className={styles.cb} style={vars}>
<span className={styles.label} id="cb-label">{label}</span>
<div className={styles.field}>
<span className={styles.mag} aria-hidden="true">
<svg viewBox="0 0 256 256" width="13" height="13">
<g fill="currentColor">
<path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2" />
<path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72" />
</g>
</svg>
</span>
<input
ref={inputRef}
id="cb-input"
className={styles.input}
type="text"
role="combobox"
aria-expanded="true"
aria-controls="cb-list"
aria-autocomplete="list"
aria-labelledby="cb-label"
aria-activedescendant={at >= 0 && visible[at] ? `cb-o${visible[at].i}` : undefined}
placeholder={placeholder}
autoComplete="off"
value={query}
onChange={(e: ChangeEvt) => {
const q = e.target.value;
setQuery(q);
setFilter(q.trim());
setAt(-1);
}}
onKeyDown={(e: KeyEvt) => {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
setAt((a) => Math.max(0, Math.min(a + (e.key === "ArrowDown" ? 1 : -1), visible.length - 1)));
e.preventDefault();
} else if (e.key === "Enter" && visible[at]) {
choose(visible[at].name);
e.preventDefault();
} else if (e.key === "Escape") {
setQuery("");
setFilter("");
setAt(-1);
inputRef.current?.focus({ preventScroll: true });
}
}}
/>
</div>
<div className={styles.box}>
<ul id="cb-list" className={styles.list} role="listbox" aria-labelledby="cb-label">
{visible.map(({ name, i }, vi) => {
const s = split(name, filter);
return (
<li
key={name}
id={`cb-o${i}`}
className={`${styles.opt} ${vi === at ? styles.isAt : ""}`}
role="option"
aria-selected={name === picked}
onMouseMove={() => setAt(vi)}
onClick={() => choose(name)}
>
{s.before}
{s.hit ? <mark className={styles.mark}>{s.hit}</mark> : null}
{s.after}
</li>
);
})}
</ul>
{visible.length === 0 ? <p className={styles.empty} id="cb-empty">{emptyText}</p> : null}
</div>
</div>
);
}03 Searchable combobox — Measured area 1.65% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · 9 Select Dropdown Design Patterns That Really Open
The post this demo lives in
9 Select Dropdown Design Patterns That Really OpenA hand-built select dropdown design replaces the gray box the browser draws for <select> with a button and a list of you See all demos in this post


