Skip to content
UI Kit
Accent color

Select for React

The kit listbox with icons, descriptions, groups and full keyboard support, plus the styled native select.

One of 9 components in Forms and inputs

2 variants

Listbox or native select

A select picks one value from a known list. This component comes in two forms that share one look, a custom listbox for rich options and the native select styled to match, because on phones the native picker is often the better tool.

Listbox shows options with an icon and a description, in groups, with a check on the chosen one. It follows the listbox pattern, arrow keys move, typing jumps to a match, Home and End reach the ends, Escape closes without changing. Native keeps the browser control with the kit styles, the right choice for countries, years and other long lists.

Use a select for five to fifteen options. Fewer than five read faster as radio buttons, more than fifteen want a combobox with search. Order options in a way people expect, alphabetical for countries, by size for plans, and preselect only when one answer is clearly the most common.

Variants

Listbox

The list unfolds from the field, its rows follow one after another, one mark slides to the active row. Arrows, Home, End and typing move through it.

Native

The browser select in the kit look. Best for long lists on phones and in long forms.

Details

What Select brings along, read from the registry and the source.

Variants
2
Version
1.0.1 · since 1.0.0
File
components/ui/select.tsx
Brings along
utilskiticoninputpopover
npm packages
None
Languages
English and German, 1 text of its own
Studio
Adjust in code

Props

The SelectProps interface from components/ui/select.tsx, with the comments from the code. Also takes every prop of FieldOwnProps.

  • optionsrequired
    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

Built-in texts

Select writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 1 texts
placeholderEnglish Choose…German Auswählen…

Accessibility

Read from the source of the component.

  • Select handles the keys Escape, Enter, Arrow up, Arrow down, Home, End, Tab and Space itself.
  • Sets aria-activedescendant, aria-controls, aria-describedby, aria-disabled, aria-expanded, aria-haspopup, aria-invalid, aria-labelledby, aria-required and aria-selected, so screen readers announce purpose and state.
  • Uses the roles combobox, group, listbox and option.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still. 2 places in the code switch motion off then.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 367 lines
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} />);
}

Frequent questions

How do I install Select?

With aiw add @ai-website/select. The command puts components/ui/select.tsx into your project, the code is yours from then on. It brings along icon, input and popover. It needs no further npm packages.

Which variants does Select have?

2 variants, Listbox and Native.

Can I translate the texts of Select?

Yes. Select carries 1 texts of its own in English and German, such as “Choose…”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Select?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Select with aiw_components and install it with aiw_add @ai-website/select.

Often searched

Jump to

Copied to the clipboard