Zum Inhalt springen
UI Kit
Accent

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

2 Variants

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.

Variants

Liste

Die Liste klappt aus dem Feld auf, die Zeilen folgen nacheinander, eine Markierung gleitet zur aktiven Zeile. Pfeile, Pos1, Ende und Tippen bewegen durch sie.

Nativ

Das Browser-Select im Kit-Look. Am besten für lange Listen auf dem Telefon und in langen Formularen.

Details

Was Auswahl mitbringt, aus Registry und Quellcode gelesen.

Bereich
Elements
Variants
2
Version
1.0.1 · seit 1.0.0
Datei
components/ui/select.tsx
Bringt mit
utilskiticoninputpopover
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 1 eigener Text
Studio
Im Code anpassen

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.

  • optionsPflicht
    SelectOption[]
  • valueoptional
    string
  • defaultValueoptional
    string
  • onValueChangeoptional
    (value: string) => void
  • placeholderoptional
    string
  • sizeoptional
    ControlSize
  • iconoptional
    IconName

    Icon at the start of the trigger

  • nameoptional
    string

    Form name, a hidden input carries the value

  • idoptional
    string
  • disabledoptional
    boolean
  • requiredoptional
    boolean
  • invalidoptional
    boolean
  • defaultOpenoptional
    boolean

    Open 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
placeholderEnglisch 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
components/ui/select.tsx
'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.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert