Combobox für React
Ein Textfeld, das beim Tippen filtert, Akzente ignoriert, den Treffer markiert und neue Einträge anlegen kann.
Eine von 7 Components in Picker und Auswahlfelder
Ein Select, in das man tippen kann
Eine Combobox ist ein Textfeld mit einer Liste, die beim Tippen filtert, das richtige Element, wenn es zu viele Optionen zum Scrollen gibt. Diese folgt dem Combobox-Muster, die Liste öffnet sich beim Tippen oder mit der Pfeiltaste, Pfeile gehen durch die Treffer, Enter wählt.
„Filtern beim Tippen“ findet Treffer an jeder Stelle des Labels und ignoriert Akzente und Groß- und Kleinschreibung, cafe findet also Café. Sie hebt den passenden Teil jeder Option hervor, nennt Screenreadern die Zahl der Ergebnisse und bietet an, einen neuen Eintrag anzulegen, wenn nichts passt, gut für Tags, Städte und Kunden, die mit der Zeit wachsen.
Nutze eine Combobox ab etwa fünfzehn Optionen. Zeig vor der ersten Taste ein paar Vorschläge, zuletzt genutzte oder beliebte Einträge helfen beim Start. Entscheide, ob neue Einträge erlaubt sind, und sag es im leeren Zustand, ein stummes Kein Treffer lässt Menschen raten.
Variants
Details
Was Combobox mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Picker und Auswahlfelder
- Bereich
- Elements
- Variants
- 1
- Version
- 1.0.1 · seit 1.0.0
- Datei
- components/ui/combobox.tsx
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 5 eigene Texte
- Studio
- Im Code anpassen
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle ComboboxProps aus components/ui/combobox.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch. Nimmt außerdem alle Props von FieldOwnProps an.
optionsPflichtSelectOption[]valueoptionalstringdefaultValueoptionalstringonValueChangeoptional(value: string) => voidonCreateoptional(text: string) => voidOffer to create the typed text when nothing matches
placeholderoptionalstringsizeoptionalControlSizeiconoptionalIconNamenameoptionalstringidoptionalstringdisabledoptionalbooleanrequiredoptionalbooleaninvalidoptionalbooleanfilteroptional(option: SelectOption, query: string) => booleanCustom matching. Default: every word of the query appears in label or description.
defaultQueryoptionalstringOpen on first render with this query, for demos
defaultOpenoptionalboolean
Eingebaute Texte
Diese Texte schreibt Combobox selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 5 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| empty | Englisch No match. Try another word. | Deutsch Kein Treffer. Versuch ein anderes Wort. |
| clear | Englisch Clear | Deutsch Leeren |
| toggle | Englisch Show options | Deutsch Optionen zeigen |
| results | Englisch results | Deutsch Treffer |
| create | Englisch Create | Deutsch Anlegen |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Combobox reagiert selbst auf die Tasten Escape, Enter, Pfeil hoch, Pfeil runter und Tab.
- Setzt aria-activedescendant, aria-autocomplete, aria-controls, aria-describedby, aria-expanded, aria-invalid, aria-label, aria-labelledby, aria-live und aria-selected, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rollen combobox, listbox und option.
- Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
- Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 1 Stellen im Code schalten Bewegung dann ab.
- Ohne Animationen, sie steht sofort ruhig da.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 252 Zeilen
'use client';
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
import { cn, useTexts } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
import { CONTROL_HEIGHT, controlBox, useControl, useShake, withField, type ControlSize, type FieldOwnProps } from '@/components/ui/input';
import { ActiveMarker, LIST_STYLE, ListboxGroups, revealOption, unfold, type SelectOption } from '@/components/ui/select';
import { Floating, panelSurface, useDismiss, useMounted } from '@/components/ui/popover';
const TEXTS = {
en: { empty: 'No match. Try another word.', clear: 'Clear', toggle: 'Show options', results: 'results', create: 'Create' },
de: { empty: 'Kein Treffer. Versuch ein anderes Wort.', clear: 'Leeren', toggle: 'Optionen zeigen', results: 'Treffer', create: 'Anlegen' },
};
export interface ComboboxProps extends FieldOwnProps {
options: SelectOption[];
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
/** Offer to create the typed text when nothing matches */
onCreate?: (text: string) => void;
placeholder?: string;
size?: ControlSize;
icon?: IconName;
name?: string;
id?: string;
disabled?: boolean;
required?: boolean;
invalid?: boolean;
/** Custom matching. Default: every word of the query appears in label or description. */
filter?: (option: SelectOption, query: string) => boolean;
/** Open on first render with this query, for demos */
defaultQuery?: string;
defaultOpen?: boolean;
labels?: Partial<(typeof TEXTS)['en']>;
className?: string;
}
const norm = (s: string) => s.toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '');
const defaultFilter = (o: SelectOption, q: string) => {
const hay = norm(`${o.label} ${o.description ?? ''}`);
return norm(q).split(/\s+/).filter(Boolean).every((w) => hay.includes(w));
};
/** Marks the typed part in a label */
function Highlight({ text, query }: { text: string; query: string }) {
const q = norm(query.trim());
if (!q) return <>{text}</>;
const i = norm(text).indexOf(q.split(/\s+/)[0]);
if (i < 0) return <>{text}</>;
const len = q.split(/\s+/)[0].length;
return (
<>
{text.slice(0, i)}
<mark className="rounded-[3px] bg-[color-mix(in_oklab,var(--primary)_16%,transparent)] text-inherit">{text.slice(i, i + len)}</mark>
{text.slice(i + len)}
</>
);
}
function ComboboxBox({ options, value: valueProp, defaultValue, onValueChange, onCreate, placeholder, size = 'md', icon = 'search', name, id, disabled, required, invalid, filter = defaultFilter, defaultQuery, defaultOpen, labels, className }: Omit<ComboboxProps, keyof FieldOwnProps>) {
const t = useTexts(TEXTS, labels);
const c = useControl({ id, invalid, disabled, required });
const [inner, setInner] = useState(defaultValue);
const value = valueProp ?? inner;
const selected = options.find((o) => o.value === value);
const [query, setQuery] = useState(defaultQuery ?? selected?.label ?? '');
const [typing, setTyping] = useState(defaultQuery !== undefined);
const [open, setOpen] = useState(Boolean(defaultOpen));
const [active, setActive] = useState(0);
const mounted = useMounted();
const boxRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const baseId = useId();
const listId = `${baseId}-list`;
const shown = useMemo(() => (typing && query.trim() ? options.filter((o) => filter(o, query)) : options), [options, query, typing, filter]);
const canCreate = Boolean(onCreate && typing && query.trim() && !options.some((o) => norm(o.label) === norm(query.trim())));
const count = shown.length + (canCreate ? 1 : 0);
const close = useCallback(() => {
setOpen(false);
setTyping(false);
setQuery((q) => (selected ? selected.label : q));
}, [selected]);
const refs = useRef([boxRef, listRef]).current;
useDismiss(open, close, refs);
useShake(boxRef, c.invalid);
useEffect(() => {
if (open) revealOption(active < shown.length ? `${baseId}-opt-${active}` : `${baseId}-create`);
}, [open, active, baseId, shown.length]);
const pick = (o: SelectOption) => {
if (o.disabled) return;
setInner(o.value);
onValueChange?.(o.value);
setQuery(o.label);
setTyping(false);
setOpen(false);
};
const create = () => {
onCreate?.(query.trim());
setTyping(false);
setOpen(false);
};
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
if (!open) {
setOpen(true);
setActive(Math.max(0, shown.findIndex((o) => o.value === value)));
return;
}
setActive((a) => (a + (e.key === 'ArrowDown' ? 1 : -1) + count) % Math.max(count, 1));
} else if (e.key === 'Enter' && open) {
e.preventDefault();
if (active < shown.length && shown[active]) pick(shown[active]);
else if (canCreate) create();
} else if (e.key === 'Escape') {
if (open) close();
else if (query) {
setQuery('');
setTyping(true);
}
} else if (e.key === 'Tab' && open) close();
};
const activeId = open && count ? (active < shown.length ? `${baseId}-opt-${active}` : `${baseId}-create`) : undefined;
return (
<>
<div ref={boxRef} data-invalid={c.invalid || undefined} data-disabled={c.disabled || undefined} className={cn('group/combo relative flex w-full items-center', CONTROL_HEIGHT[size], controlBox, className)}>
<Icon name={icon} size={16} className="pointer-events-none ms-3.5 shrink-0 text-subtle-foreground transition-colors duration-300 group-focus-within/combo:text-muted-foreground" />
<input
ref={inputRef}
id={c.id}
role="combobox"
aria-autocomplete="list"
aria-expanded={open}
aria-controls={open ? listId : undefined}
aria-activedescendant={activeId}
aria-invalid={c['aria-invalid']}
aria-describedby={c['aria-describedby']}
disabled={c.disabled}
required={c.required}
autoComplete="off"
spellCheck={false}
placeholder={placeholder}
value={query}
onChange={(e) => {
setQuery(e.target.value);
setTyping(true);
setActive(0);
setOpen(true);
}}
onClick={() => setOpen(true)}
onKeyDown={onKeyDown}
className="h-full w-0 min-w-0 flex-1 bg-transparent ps-2.5 text-inherit outline-none placeholder:text-subtle-foreground"
/>
{query && !c.disabled && (
<button
type="button"
aria-label={t.clear}
onClick={() => {
setQuery('');
setTyping(true);
setOpen(true);
inputRef.current?.focus();
}}
className="relative grid size-7 place-items-center rounded-md text-subtle-foreground transition-[background-color,color,scale] duration-200 after:absolute after:-inset-2 after:content-[''] hover:bg-accent hover:text-foreground active:scale-90 starting:scale-50 starting:opacity-0 motion-reduce:transition-none"
>
<Icon name="x" size={14} />
</button>
)}
<button
type="button"
tabIndex={-1}
aria-label={t.toggle}
onClick={() => {
setOpen(!open);
inputRef.current?.focus();
}}
className="me-1.5 grid size-7 place-items-center rounded-md text-subtle-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<Icon name="chevrons-up-down" size={15} className={cn('transition-[scale] duration-300 ease-[var(--ease-kit)]', open && 'scale-110')} />
</button>
</div>
{name && <input type="hidden" name={name} value={value ?? ''} />}
<span aria-live="polite" className="sr-only">
{open && typing ? `${shown.length} ${t.results}` : ''}
</span>
{mounted && open && (
<Floating anchor={boxRef} side="bottom" align="start" offset={6} matchWidth ref={listRef}>
{(pos) => (
<div
id={listId}
role="listbox"
aria-labelledby={c.id}
className={cn('kit-scrollbar relative max-h-72 overflow-y-auto overscroll-contain p-1.5', panelSurface, unfold(pos?.side).className)}
>
<style dangerouslySetInnerHTML={{ __html: LIST_STYLE }} />
<ActiveMarker id={activeId} version={`${query}|${shown.length}`} />
{shown.length > 0 && (
<ListboxGroups
options={shown}
baseId={`${baseId}-opt`}
activeIndex={active}
isSelected={(o) => o.value === value}
onPick={pick}
onHover={(i) => setActive(i)}
render={(o) => <Highlight text={o.label} query={typing ? query : ''} />}
/>
)}
{canCreate && (
<div
id={`${baseId}-create`}
role="option"
aria-selected={false}
data-active={active === shown.length || undefined}
onPointerMove={() => setActive(shown.length)}
onPointerDown={(e) => e.preventDefault()}
onClick={create}
style={{ '--i': shown.length } as React.CSSProperties}
className="kitfx-select-row relative flex min-h-9 cursor-pointer items-center gap-2.5 rounded-md px-2.5 text-sm text-foreground max-sm:min-h-11"
>
<Icon name="plus" size={16} className="text-muted-foreground" />
<span className="truncate">
{t.create} <span className="font-medium">{query.trim()}</span>
</span>
</div>
)}
{count === 0 && <p className="px-3 py-6 text-center text-sm text-muted-foreground">{t.empty}</p>}
</div>
)}
</Floating>
)}
</>
);
}
/**
* A text field that filters a list as you type. Arrows move, Enter picks, Escape closes then clears.
* Ignores accents and word order, highlights the match, can offer to create a new entry.
* <Combobox label="Country" options={countries} placeholder="Search a country" />
*/
export function Combobox({ label, hint, error, optional, labelAction, className, ...rest }: ComboboxProps) {
const wrapped = Boolean(label || hint || error);
return withField({ label, hint, error, optional, labelAction, id: rest.id, required: rest.required, disabled: rest.disabled, className }, <ComboboxBox {...rest} className={wrapped ? undefined : className} />);
}Häufige Fragen
Wie installiere ich Combobox?
Mit aiw add @ai-website/combobox. Der Befehl legt die Datei components/ui/combobox.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon, input, select und popover. Weitere npm-Pakete braucht sie nicht.
Kann ich die Texte von Combobox übersetzen?
Ja. Combobox bringt 5 eigene Texte auf Deutsch und Englisch mit, etwa „Kein Treffer. Versuch ein anderes Wort.“, „Leeren“ und „Optionen zeigen“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Combobox einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Combobox mit aiw_components und installieren sie mit aiw_add @ai-website/combobox.