|Design System

Core Component

Select

Dropdown select with keyboard navigation, origin-aware positioning, and ARIA listbox pattern.

Preview

  • Claude 3.5 Sonnet
  • GPT-4o
  • Gemini Pro
  • Llama 3.1 70B

Source

Full component implementation using the design system tokens.

tsx
"use client";

import { useState, useRef, useEffect } from "react";
import { ChevronDown, Check } from "lucide-react";

export function DSSelect({
  options,
  value,
  onChange,
  placeholder = "Select…",
  label,
  disabled = false,
}: {
  options: { value: string; label: string }[];
  value?: string;
  onChange?: (value: string) => void;
  placeholder?: string;
  label?: string;
  disabled?: boolean;
}) {
  const [open, setOpen] = useState(false);
  const [selected, setSelected] = useState(value ?? "");
  const [highlighted, setHighlighted] = useState(-1);
  const triggerRef = useRef<HTMLButtonElement>(null);
  const listRef = useRef<HTMLUListElement>(null);

  useEffect(() => {
    if (value !== undefined) setSelected(value);
  }, [value]);

  useEffect(() => {
    if (!open) return;
    const handleClick = (e: MouseEvent) => {
      if (
        triggerRef.current?.contains(e.target as Node) ||
        listRef.current?.contains(e.target as Node)
      )
        return;
      setOpen(false);
    };
    document.addEventListener("mousedown", handleClick);
    return () => document.removeEventListener("mousedown", handleClick);
  }, [open]);

  const select = (val: string) => {
    setSelected(val);
    onChange?.(val);
    setOpen(false);
    triggerRef.current?.focus();
  };

  const handleKeyDown = (e: React.KeyboardEvent) => {
    if (!open) {
      if (["ArrowDown", "ArrowUp", "Enter", " "].includes(e.key)) {
        e.preventDefault();
        setOpen(true);
      }
      return;
    }
    switch (e.key) {
      case "ArrowDown": e.preventDefault(); setHighlighted(h => Math.min(h + 1, options.length - 1)); break;
      case "ArrowUp": e.preventDefault(); setHighlighted(h => Math.max(h - 1, 0)); break;
      case "Enter": case " ": e.preventDefault(); if (highlighted >= 0) select(options[highlighted].value); break;
      case "Escape": e.preventDefault(); setOpen(false); triggerRef.current?.focus(); break;
    }
  };

  const selectedLabel = options.find(o => o.value === selected)?.label;

  return (
    <div className="relative">
      {label && <label className="block text-sm font-medium text-foreground mb-1.5">{label}</label>}
      <button
        ref={triggerRef}
        type="button"
        role="combobox"
        aria-expanded={open}
        aria-haspopup="listbox"
        disabled={disabled}
        onClick={() => setOpen(!open)}
        onKeyDown={handleKeyDown}
        className={`flex items-center justify-between w-full px-4 py-2.5 text-sm rounded-lg border bg-surface border-overlay/10 transition-all duration-150 active:scale-[0.97] ${open ? "border-accent" : "hover:border-overlay/20"} ${disabled ? "opacity-50 cursor-not-allowed" : ""}`}
        style={{ transitionTimingFunction: "var(--ease-out)" }}
      >
        <span className={selectedLabel ? "text-foreground" : "text-tertiary"}>
          {selectedLabel || placeholder}
        </span>
        <ChevronDown className="w-4 h-4 text-tertiary shrink-0 ml-2" />
      </button>
      <div
        className="absolute z-50 left-0 right-0 mt-1"
        style={{
          opacity: open ? 1 : 0,
          transform: open ? "scale(1)" : "scale(0.95)",
          transformOrigin: "top",
          pointerEvents: open ? "auto" : "none",
          transition: "opacity 150ms var(--ease-out), transform 150ms var(--ease-out)",
        }}
      >
        <ul ref={listRef} role="listbox" className="py-1 rounded-lg border border-overlay/10 bg-surface shadow-lg max-h-60 overflow-auto" onKeyDown={handleKeyDown}>
          {options.map((option, i) => (
            <li
              key={option.value}
              role="option"
              aria-selected={option.value === selected}
              className={`flex items-center justify-between px-4 py-2 text-sm cursor-pointer transition-colors ${i === highlighted ? "bg-overlay/5 text-foreground" : "text-secondary hover:bg-overlay/5"} ${option.value === selected ? "font-medium" : ""}`}
              onClick={() => select(option.value)}
              onMouseEnter={() => setHighlighted(i)}
            >
              {option.label}
              {option.value === selected && <Check className="w-4 h-4 text-accent shrink-0" />}
            </li>
          ))}
        </ul>
      </div>
    </div>
  );
}

Props

All available props with types and defaults.

PropTypeDescription
options*{ value: string; label: string }[]Array of selectable options
valuestringControlled selected value
onChange(value: string) => voidCalled when selection changes
placeholderstringPlaceholder text when nothing selected
labelstringLabel displayed above the select
disabledbooleanDisables the select

Variants

Default

Basic select with placeholder.

  • Claude 3.5 Sonnet
  • GPT-4o
  • Gemini Pro
tsx
<DSSelect
  options={[
    { value: "gpt4", label: "GPT-4o" },
    { value: "claude", label: "Claude 3.5 Sonnet" },
    { value: "gemini", label: "Gemini Pro" },
  ]}
  placeholder="Choose a model"
/>

With Label

Labeled select for form contexts.

  • Claude 3.5 Sonnet
  • GPT-4o
  • Gemini Pro
tsx
<DSSelect
  label="Model"
  options={[
    { value: "gpt4", label: "GPT-4o" },
    { value: "claude", label: "Claude 3.5 Sonnet" },
  ]}
/>

Disabled

Non-interactive disabled state.

    tsx
    <DSSelect options={[]} placeholder="Unavailable" disabled />

    Prompt Guide

    Prompt Guide — Select

    Use for

    • Model/provider selection
    • Language or locale pickers
    • Temperature or preset selectors
    • Any single-choice from a defined list

    Don't use for

    • Multi-select — use checkboxes instead
    • Fewer than 3 options — use radio group
    • Free-text entry — use Input with autocomplete

    AI Context

    The Select is a combobox with listbox ARIA. It opens from the trigger with origin-aware scale animation (150ms). Keyboard: Arrow keys navigate, Enter selects, Escape closes. In AI interfaces, use for model selection dropdowns or parameter presets.