Select für React
Die Kit-Liste mit Icons, Beschreibungen, Gruppen und voller Tastatur, dazu das gestylte native Select.
Eine von 9 Components in Forms und Inputs
Liste oder natives Select
Ein Select wählt einen Wert aus einer bekannten Liste. Diese Component kommt in zwei Formen mit einem Aussehen, als eigene Listbox für reichhaltige Optionen und als gestaltetes natives Select, denn am Handy ist der native Picker oft das bessere Werkzeug.
„Liste“ zeigt Optionen mit Icon und Beschreibung, in Gruppen, mit Haken an der gewählten. Sie folgt dem Listbox-Muster, Pfeiltasten bewegen, Tippen springt zum Treffer, Pos1 und Ende erreichen die Enden, Escape schließt ohne Änderung. „Nativ“ behält das Browser-Element mit den Styles des Kits, die richtige Wahl für Länder, Jahre und andere lange Listen.
Nutze ein Select für fünf bis fünfzehn Optionen. Unter fünf lesen sich Radio Buttons schneller, über fünfzehn braucht es eine Combobox mit Suche. Ordne Optionen so, wie Menschen es erwarten, alphabetisch bei Ländern, nach Größe bei Plänen, und wähle nur vor, wenn eine Antwort klar die häufigste ist.
Details
Was Auswahl mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Forms und Inputs
- Bereich
- Elements
- Variants
- 2
- Version
- 1.0.1 · seit 1.0.0
- Datei
- components/ui/select.tsx
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 1 eigener Text
- Studio
- Im Code anpassen
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle SelectProps aus components/ui/select.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch. Nimmt außerdem alle Props von FieldOwnProps an.
optionsPflichtSelectOption[]valueoptionalstringdefaultValueoptionalstringonValueChangeoptional(value: string) => voidplaceholderoptionalstringsizeoptionalControlSizeiconoptionalIconNameIcon at the start of the trigger
nameoptionalstringForm name, a hidden input carries the value
idoptionalstringdisabledoptionalbooleanrequiredoptionalbooleaninvalidoptionalbooleandefaultOpenoptionalbooleanOpen on first render, for demos
Eingebaute Texte
Diese Texte schreibt Auswahl selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 1 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| placeholder | Englisch Choose… | Deutsch Auswählen… |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Auswahl reagiert selbst auf die Tasten Escape, Enter, Pfeil hoch, Pfeil runter, Pos1, Ende, Tab und Leertaste.
- Setzt aria-activedescendant, aria-controls, aria-describedby, aria-disabled, aria-expanded, aria-haspopup, aria-invalid, aria-labelledby, aria-required und aria-selected, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rollen combobox, group, listbox und option.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 2 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 367 Zeilen
'use client';
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
import { cn, useTexts } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
import { CONTROL_HEIGHT, CONTROL_PAD, controlBox, controlBoxFocus, useControl, useShake, withField, type ControlSize, type FieldOwnProps } from '@/components/ui/input';
import { Floating, panelSurface, useDismiss, useMounted } from '@/components/ui/popover';
const TEXTS = { en: { placeholder: 'Choose…' }, de: { placeholder: 'Auswählen…' } };
// useLayoutEffect warns on the server, the measurement only matters in the browser
const useIsoLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect;
/*
* List motion, shared with the combobox and the date input. The list unfolds
* from the edge that touches the field (clip-path from that edge plus a
* short drop), the rows follow one after another, one mark slides to the
* active row. The keyframes live here, prefixed, with a reduced motion rule.
*/
export const LIST_STYLE = `
@keyframes kitfx-select-row { from { opacity: 0; transform: translateY(-4px); } }
.kitfx-select-row { animation: kitfx-select-row 0.32s cubic-bezier(0.2,0.7,0.2,1) both; animation-delay: calc(min(var(--i, 0), 8) * 22ms + 40ms); }
@media (prefers-reduced-motion: reduce) { .kitfx-select-row { animation: none; } }
`;
/** Classes and style of a floating list that unfolds from the field. side: where the list sits. */
export function unfold(side: string | undefined) {
const up = side === 'top';
return {
className:
'transition-[opacity,translate,clip-path] duration-[260ms] ease-[var(--ease-kit)] [clip-path:inset(-40px_-40px_-60px_-40px)] starting:opacity-0 motion-reduce:transition-none ' +
(up ? 'starting:translate-y-1.5 starting:[clip-path:inset(100%_-40px_-60px_-40px)]' : 'starting:-translate-y-1.5 starting:[clip-path:inset(-40px_-40px_100%_-40px)]'),
};
}
/** The highlight that slides to the active row of a listbox. Place it first inside the (relative) list. */
export function ActiveMarker({ id, version }: { id?: string; version?: unknown }) {
const ref = useRef<HTMLSpanElement>(null);
const [box, setBox] = useState<{ x: number; y: number; w: number; h: number; animate: boolean } | null>(null);
const shown = useRef(false);
useIsoLayoutEffect(() => {
const el = id ? document.getElementById(id) : null;
const list = ref.current?.parentElement;
if (!el || !list) {
shown.current = false;
return setBox(null);
}
// offset* ignore the rows' entrance transform, so the mark lands where the row ends up
let x = 0;
let y = 0;
for (let n: HTMLElement | null = el; n && n !== list; n = n.offsetParent as HTMLElement | null) {
x += n.offsetLeft;
y += n.offsetTop;
}
setBox({ x, y, w: el.offsetWidth, h: el.offsetHeight, animate: shown.current });
shown.current = true;
}, [id, version]);
return (
<span
ref={ref}
aria-hidden
className={cn(
'pointer-events-none absolute left-0 top-0 rounded-md bg-accent',
box?.animate && 'transition-[translate,height,width,opacity] duration-200 ease-[var(--ease-kit)] motion-reduce:transition-none',
!box && 'opacity-0',
)}
style={box ? { translate: `${box.x}px ${box.y}px`, width: box.w, height: box.h } : undefined}
/>
);
}
export interface SelectOption {
value: string;
label: string;
description?: string;
icon?: IconName;
/** Anything small before the label instead of an icon, e.g. a colored dot or an avatar */
leading?: React.ReactNode;
/** Quiet text at the end of the row, e.g. a price or a count */
meta?: React.ReactNode;
group?: string;
disabled?: boolean;
}
/* ------------------------------------------------------------------ */
/* Native select */
/* ------------------------------------------------------------------ */
export interface NativeSelectProps extends FieldOwnProps, Omit<React.SelectHTMLAttributes<HTMLSelectElement>, 'size'> {
size?: ControlSize;
options?: { value: string; label: string; disabled?: boolean }[];
placeholder?: string;
icon?: IconName;
invalid?: boolean;
className?: string;
}
function NativeBox({ size = 'md', options, placeholder, icon, invalid, className, children, ...rest }: Omit<NativeSelectProps, keyof FieldOwnProps>) {
const c = useControl({ ...rest, invalid });
const boxRef = useRef<HTMLDivElement>(null);
useShake(boxRef, c.invalid);
return (
<div ref={boxRef} data-invalid={c.invalid || undefined} data-disabled={c.disabled || undefined} className={cn('group/select relative flex w-full items-center', CONTROL_HEIGHT[size], controlBox, className)}>
{icon && <Icon name={icon} size={16} className="pointer-events-none absolute start-3.5 text-subtle-foreground" />}
<select
{...rest}
id={c.id}
disabled={c.disabled}
required={c.required}
aria-invalid={c['aria-invalid']}
aria-describedby={c['aria-describedby']}
className={cn('h-full w-full cursor-pointer appearance-none rounded-[inherit] bg-transparent pe-10 text-inherit outline-none disabled:cursor-not-allowed', CONTROL_PAD[size], icon && 'ps-10', '[&:has(option[value=""]:checked)]:text-subtle-foreground')}
>
{placeholder && (
<option value="" disabled>
{placeholder}
</option>
)}
{options?.map((o) => (
<option key={o.value} value={o.value} disabled={o.disabled}>
{o.label}
</option>
))}
{children}
</select>
<Icon name="chevrons-up-down" size={15} className="pointer-events-none absolute end-3 text-subtle-foreground transition-[color,scale] duration-300 group-focus-within/select:scale-110 group-focus-within/select:text-muted-foreground" />
</div>
);
}
/** The browser's own select, styled like the kit. Best on phones and in long forms. */
export function NativeSelect({ label, hint, error, optional, labelAction, className, ...rest }: NativeSelectProps) {
const wrapped = Boolean(label || hint || error);
return withField({ label, hint, error, optional, labelAction, id: rest.id, required: rest.required, disabled: rest.disabled, className }, <NativeBox {...rest} className={wrapped ? undefined : className} />);
}
/* ------------------------------------------------------------------ */
/* Custom select (listbox) */
/* ------------------------------------------------------------------ */
export interface SelectProps extends FieldOwnProps {
options: SelectOption[];
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
placeholder?: string;
size?: ControlSize;
/** Icon at the start of the trigger */
icon?: IconName;
/** Form name, a hidden input carries the value */
name?: string;
id?: string;
disabled?: boolean;
required?: boolean;
invalid?: boolean;
/** Open on first render, for demos */
defaultOpen?: boolean;
labels?: Partial<(typeof TEXTS)['en']>;
className?: string;
}
/** One option row, shared with the combobox */
export function OptionRow({ option, selected, active, id, index = 0, onPick, onHover, children }: { option: SelectOption; selected: boolean; active: boolean; id: string; index?: number; onPick: () => void; onHover: () => void; children?: React.ReactNode }) {
return (
<div
id={id}
role="option"
aria-selected={selected}
aria-disabled={option.disabled || undefined}
data-active={active || undefined}
onPointerMove={onHover}
onPointerDown={(e) => e.preventDefault()}
onClick={() => !option.disabled && onPick()}
style={{ '--i': index } as React.CSSProperties}
className={cn(
'kitfx-select-row relative flex min-h-9 cursor-pointer select-none items-center gap-2.5 rounded-md py-1.5 pe-8 ps-2.5 text-sm text-foreground max-sm:min-h-11',
'aria-disabled:cursor-not-allowed aria-disabled:opacity-45',
)}
>
{option.leading}
{option.icon && <Icon name={option.icon} size={16} className="shrink-0 text-muted-foreground" />}
<span className="min-w-0 flex-1">
<span className="block truncate">{children ?? option.label}</span>
{option.description && <span className="block truncate text-xs text-muted-foreground">{option.description}</span>}
</span>
{option.meta && <span className="shrink-0 text-xs tabular-nums text-subtle-foreground">{option.meta}</span>}
{selected && <Icon name="check" size={15} strokeWidth={2} className="absolute end-2.5 text-foreground" />}
</div>
);
}
/** Scrolls an option into its list without moving the page */
export function revealOption(id: string) {
const el = document.getElementById(id);
const list = el?.closest<HTMLElement>('[role=listbox]');
if (!el || !list) return;
const r = el.getBoundingClientRect();
const lr = list.getBoundingClientRect();
if (r.top < lr.top + 6) list.scrollTop -= lr.top + 6 - r.top;
else if (r.bottom > lr.bottom - 6) list.scrollTop += r.bottom - lr.bottom + 6;
}
/** Groups options while keeping their order */
export function groupOptions(options: SelectOption[]) {
const groups: { name?: string; items: { option: SelectOption; index: number }[] }[] = [];
options.forEach((option, index) => {
const last = groups[groups.length - 1];
if (last && last.name === option.group) last.items.push({ option, index });
else groups.push({ name: option.group, items: [{ option, index }] });
});
return groups;
}
export function ListboxGroups({ options, baseId, activeIndex, isSelected, onPick, onHover, render }: { options: SelectOption[]; baseId: string; activeIndex: number; isSelected: (o: SelectOption) => boolean; onPick: (o: SelectOption) => void; onHover: (i: number) => void; render?: (o: SelectOption) => React.ReactNode }) {
return (
<>
{groupOptions(options).map((g, gi) => {
const rows = g.items.map(({ option, index }) => (
<OptionRow key={option.value} id={`${baseId}-${index}`} index={index} option={option} selected={isSelected(option)} active={index === activeIndex} onPick={() => onPick(option)} onHover={() => onHover(index)}>
{render?.(option)}
</OptionRow>
));
if (!g.name) return <div key={gi}>{rows}</div>;
return (
<div key={gi} role="group" aria-labelledby={`${baseId}-g${gi}`} className={cn(gi > 0 && 'mt-1 border-t border-border pt-1')}>
<div id={`${baseId}-g${gi}`} className="px-2.5 pb-1 pt-2 text-[11px] font-medium uppercase tracking-[0.08em] text-subtle-foreground">
{g.name}
</div>
{rows}
</div>
);
})}
</>
);
}
const nextEnabled = (options: SelectOption[], from: number, dir: 1 | -1) => {
for (let i = 1; i <= options.length; i++) {
const j = (from + dir * i + options.length * 2) % options.length;
if (!options[j].disabled) return j;
}
return from;
};
function SelectBox({ options, value: valueProp, defaultValue, onValueChange, placeholder, size = 'md', icon, name, disabled, required, invalid, defaultOpen, labels, className, id }: Omit<SelectProps, keyof FieldOwnProps>) {
const t = useTexts(TEXTS, labels);
const c = useControl({ id, invalid, disabled, required });
const [inner, setInner] = useState(defaultValue);
const value = valueProp ?? inner;
const [open, setOpen] = useState(Boolean(defaultOpen));
const [active, setActive] = useState(-1);
const mounted = useMounted();
const triggerRef = useRef<HTMLButtonElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const typed = useRef({ text: '', at: 0 });
const baseId = useId();
const listId = `${baseId}-list`;
const selectedIndex = options.findIndex((o) => o.value === value);
const selected = options[selectedIndex];
const show = (at?: number) => {
setActive(at ?? (selectedIndex >= 0 ? selectedIndex : nextEnabled(options, -1, 1)));
setOpen(true);
};
const close = useCallback(() => setOpen(false), []);
const pick = (o: SelectOption) => {
if (o.disabled) return;
setInner(o.value);
onValueChange?.(o.value);
setOpen(false);
triggerRef.current?.focus();
};
const refs = useRef([triggerRef, listRef]).current;
useDismiss(open, close, refs);
useShake(triggerRef, c.invalid);
useEffect(() => {
if (open && active >= 0) revealOption(`${baseId}-${active}`);
}, [open, active, baseId]);
useEffect(() => {
if (defaultOpen && selectedIndex >= 0) setActive(selectedIndex);
}, []);
const typeahead = (key: string) => {
const now = Date.now();
typed.current = { text: (now - typed.current.at > 600 ? '' : typed.current.text) + key.toLowerCase(), at: now };
const start = open ? active : selectedIndex;
const order = options.map((_, i) => (start + 1 + i) % options.length);
const hit = order.find((i) => !options[i].disabled && options[i].label.toLowerCase().startsWith(typed.current.text));
if (hit === undefined) return;
if (open) setActive(hit);
else pick(options[hit]);
};
const onKeyDown = (e: React.KeyboardEvent) => {
if (!open) {
if (['ArrowDown', 'ArrowUp', 'Enter', ' '].includes(e.key)) {
e.preventDefault();
show();
} else if (e.key.length === 1 && !e.metaKey && !e.ctrlKey) typeahead(e.key);
return;
}
if (e.key === 'ArrowDown') { e.preventDefault(); setActive((a) => nextEnabled(options, a, 1)); }
else if (e.key === 'ArrowUp') { e.preventDefault(); setActive((a) => nextEnabled(options, a, -1)); }
else if (e.key === 'Home') { e.preventDefault(); setActive(nextEnabled(options, -1, 1)); }
else if (e.key === 'End') { e.preventDefault(); setActive(nextEnabled(options, options.length, -1)); }
else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (options[active]) pick(options[active]); }
else if (e.key === 'Tab') setOpen(false);
else if (e.key === 'Escape') { e.preventDefault(); setOpen(false); }
else if (e.key.length === 1 && !e.metaKey && !e.ctrlKey) typeahead(e.key);
};
return (
<>
<button
ref={triggerRef}
type="button"
id={c.id}
role="combobox"
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={open ? listId : undefined}
aria-activedescendant={open && active >= 0 ? `${baseId}-${active}` : undefined}
aria-invalid={c['aria-invalid']}
aria-describedby={c['aria-describedby']}
aria-required={c.required || undefined}
disabled={c.disabled}
onClick={() => (open ? setOpen(false) : show())}
onKeyDown={onKeyDown}
className={cn('flex w-full min-w-0 items-center gap-2.5 text-left', CONTROL_HEIGHT[size], CONTROL_PAD[size], 'pe-3', controlBoxFocus, open && 'border-[color-mix(in_oklab,var(--primary)_60%,var(--border-strong))]', className)}
>
{icon && <Icon name={icon} size={16} className="shrink-0 text-subtle-foreground" />}
{selected?.leading}
{selected?.icon && !icon && <Icon name={selected.icon} size={16} className="shrink-0 text-muted-foreground" />}
<span className={cn('min-w-0 flex-1 truncate', !selected && 'text-subtle-foreground')}>{selected?.label ?? placeholder ?? t.placeholder}</span>
<Icon name="chevrons-up-down" size={15} className={cn('shrink-0 text-subtle-foreground transition-[color,scale] duration-300 ease-[var(--ease-kit)]', open && 'scale-110 text-muted-foreground')} />
</button>
{name && <input type="hidden" name={name} value={value ?? ''} />}
{mounted && open && (
<Floating anchor={triggerRef} 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={active >= 0 ? `${baseId}-${active}` : undefined} />
<ListboxGroups options={options} baseId={baseId} activeIndex={active} isSelected={(o) => o.value === value} onPick={pick} onHover={(i) => !options[i].disabled && setActive(i)} />
</div>
)}
</Floating>
)}
</>
);
}
/**
* A select with the kit's own list: icons, descriptions, groups, full keyboard support
* (arrows, Home, End, typeahead, Enter, Escape). Focus stays on the trigger.
* <Select label="Plan" options={[{ value: 'team', label: 'Team', description: 'Up to 20 seats' }]} />
*/
export function Select({ label, hint, error, optional, labelAction, className, ...rest }: SelectProps) {
const wrapped = Boolean(label || hint || error);
return withField({ label, hint, error, optional, labelAction, id: rest.id, required: rest.required, disabled: rest.disabled, className }, <SelectBox {...rest} className={wrapped ? undefined : className} />);
}Häufige Fragen
Wie installiere ich Auswahl?
Mit aiw add @ai-website/select. Der Befehl legt die Datei components/ui/select.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon, input und popover. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Auswahl?
2 Varianten, Liste und Nativ.
Kann ich die Texte von Auswahl übersetzen?
Ja. Auswahl bringt 1 eigene Texte auf Deutsch und Englisch mit, etwa „Auswählen…“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Auswahl einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Auswahl mit aiw_components und installieren sie mit aiw_add @ai-website/select.