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.
| Prop | Type | Default | Description |
|---|---|---|---|
options* | { value: string; label: string }[] | — | Array of selectable options |
value | string | — | Controlled selected value |
onChange | (value: string) => void | — | Called when selection changes |
placeholder | string | 'Select…' | Placeholder text when nothing selected |
label | string | — | Label displayed above the select |
disabled | boolean | false | Disables 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.