# Select

> Keyboard-friendly listbox with a glowing popover and typeahead.

Category: Forms. Docs: https://get-neonkit.vercel.app/components/select

## Installation

```bash
npx shadcn@latest add https://get-neonkit.vercel.app/r/select.json
```

With the `@neonkit` namespace configured: `npx shadcn@latest add @neonkit/select`.

npm dependencies: `motion`, `lucide-react`. Also needs `lib/utils.ts` (`cn`) and `lib/neon.ts` (`neonVars`), which the CLI adds.

## Usage

```tsx
import { Select } from "@/components/ui/select"

<Select
  label="Region"
  defaultValue="fra1"
  options={[
    { value: "fra1", label: "Frankfurt" },
    { value: "iad1", label: "Washington, D.C." },
  ]}
/>
```

## Examples

### Region picker

Options can carry a second line. Disabled options are skipped by the keyboard.

```tsx
import { Select } from "@/components/ui/select";

const regions = [
  { value: "fra1", label: "Frankfurt", description: "eu-central-1 · 12 ms" },
  { value: "lhr1", label: "London", description: "eu-west-2 · 18 ms" },
  { value: "iad1", label: "Washington, D.C.", description: "us-east-1 · 84 ms" },
  { value: "sfo1", label: "San Francisco", description: "us-west-1 · 142 ms" },
  { value: "gru1", label: "São Paulo", description: "sa-east-1 · 196 ms" },
  { value: "hnd1", label: "Tokyo", description: "ap-northeast-1 · 228 ms" },
  { value: "syd1", label: "Sydney", description: "ap-southeast-2 · At capacity", disabled: true },
];

export default function SelectDemo() {
  return (
    <Select
      label="Region"
      description="Where your functions run. Pick the one closest to your database."
      options={regions}
      defaultValue="fra1"
      containerClassName="max-w-xs"
    />
  );
}
```

### In a form

Give it a name and the value submits with the form through a hidden input.

```tsx
"use client";

import * as React from "react";
import { GitBranch, GitPullRequest, Rocket, Tag } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Select } from "@/components/ui/select";

const refs = [
  { value: "main", label: "main", icon: <GitBranch />, description: "Production · deployed 2 hours ago" },
  { value: "release/2.4", label: "release/2.4", icon: <GitBranch />, description: "Release candidate" },
  { value: "feat/otp-input", label: "feat/otp-input", icon: <GitPullRequest />, description: "#128 · Preview ready" },
  { value: "fix/select-typeahead", label: "fix/select-typeahead", icon: <GitPullRequest />, description: "#131 · Checks failing", disabled: true },
  { value: "v2.3.1", label: "v2.3.1", icon: <Tag />, description: "Tag · last stable" },
];

export default function SelectDeployBranch() {
  const [status, setStatus] = React.useState("");

  function deploy(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const branch = new FormData(event.currentTarget).get("branch");
    setStatus(`Queued ${branch} for production.`);
  }

  return (
    <form onSubmit={deploy} className="grid w-full max-w-sm gap-2">
      <div className="flex items-end gap-2">
        <Select
          name="branch"
          label="Deploy branch"
          options={refs}
          defaultValue="main"
          onValueChange={() => setStatus("")}
          containerClassName="flex-1"
        />
        <Button type="submit" variant="solid">
          <Rocket /> Deploy
        </Button>
      </div>
      <p aria-live="polite" className="h-5 text-xs leading-5 text-white/55">
        {status}
      </p>
    </form>
  );
}
```

### Sizes

```tsx
import { Select } from "@/components/ui/select";

const sorts = [
  { value: "updated", label: "Recently updated" },
  { value: "created", label: "Newest first" },
  { value: "stars", label: "Most stars" },
  { value: "name", label: "Name" },
];

export default function SelectSizes() {
  return (
    <div className="grid w-full max-w-xs gap-3">
      <Select size="sm" aria-label="Sort repositories" options={sorts} defaultValue="updated" />
      <Select size="md" aria-label="Sort repositories" options={sorts} defaultValue="created" />
      <Select size="lg" aria-label="Sort repositories" options={sorts} defaultValue="stars" />
    </div>
  );
}
```

### Error and disabled

```tsx
import { Select } from "@/components/ui/select";

const plans = [
  { value: "hobby", label: "Hobby" },
  { value: "pro", label: "Pro" },
  { value: "enterprise", label: "Enterprise" },
];

const timezones = [
  { value: "America/Los_Angeles", label: "Pacific Time", description: "Los Angeles · UTC−8" },
  { value: "America/New_York", label: "Eastern Time", description: "New York · UTC−5" },
  { value: "Europe/London", label: "Greenwich Mean Time", description: "London · UTC+0" },
  { value: "Europe/Berlin", label: "Central European Time", description: "Berlin · UTC+1" },
  { value: "Asia/Kolkata", label: "India Standard Time", description: "Kolkata · UTC+5:30" },
  { value: "Asia/Tokyo", label: "Japan Standard Time", description: "Tokyo · UTC+9" },
];

export default function SelectStates() {
  return (
    <div className="grid w-full max-w-xs gap-5">
      <Select
        label="Timezone"
        placeholder="Choose a timezone"
        options={timezones}
        error="Pick a timezone so the weekly report lands on Monday morning."
      />
      <Select
        label="Plan"
        options={plans}
        defaultValue="pro"
        description="Only workspace owners can change the plan."
        disabled
      />
    </div>
  );
}
```

## API reference

### Select

Also accepts native button attributes, and the ref points at the trigger. className and style go to the trigger.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `options` | `SelectOption[]` | — | The choices, in order. |
| `value` | `string` | — | Controlled value. |
| `defaultValue` | `string` | — | Initial value when uncontrolled. |
| `onValueChange` | `(value: string) => void` | — | Called when the user picks a different option. |
| `placeholder` | `ReactNode` | `"Select…"` | Shown while nothing is selected. |
| `name` | `string` | — | Submits the value with a surrounding form. |
| `label` | `ReactNode` | — | Visible label. Names both the trigger and the list. |
| `description` | `ReactNode` | — | Helper text under the trigger. |
| `error` | `ReactNode` | — | Red invalid state and message. Replaces description. Pass true for the state without a message. |
| `disabled` | `boolean` | `false` | Blocks opening and leaves the value out of form data. |
| `color` | `NeonColor` | `inherited` | Palette name or hex. Falls back to --neon-accent, then cyan. |
| `size` | `"sm" \| "md" \| "lg"` | `"md"` | Trigger height and option padding. |
| `containerClassName` | `string` | — | Classes for the outer block with the label, list and message. |

### SelectOption

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `string` | — | Unique value, also the React key. |
| `label` | `string` | — | Shown in the list and the trigger. Typeahead matches against it. |
| `description` | `string` | — | Second line, shown in the list only. |
| `icon` | `ReactNode` | — | Shown before the label in the list and the trigger. |
| `disabled` | `boolean` | `false` | Visible but can't be picked. |

## Accessibility

- Follows the WAI-ARIA select-only combobox pattern: focus stays on the trigger and aria-activedescendant tracks the highlighted option.
- Enter, Space and the arrow keys open the list. Arrows move, Home and End jump, Enter or Space picks, Escape and Tab close.
- Typing letters jumps to the first matching option. Pressing the same letter again cycles through matches.
- Without a visible label, pass aria-label. It names both the trigger and the list.
- The list is positioned below the trigger, so an overflow-hidden ancestor will clip it.

## Source

`components/ui/select.tsx`

```tsx
"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check, ChevronDown } from "lucide-react";

import { cn } from "@/lib/utils";
import { neonVars, type NeonColor } from "@/lib/neon";

const INVALID = "#ff4d5e";
const EASE_OUT = [0.23, 1, 0.32, 1] as const;

const useIsomorphicLayoutEffect =
  typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;

const sizes = {
  sm: {
    trigger: "h-8 gap-2 rounded-md px-2.5 text-xs [&_svg]:size-3.5",
    list: "rounded-lg",
    option: "gap-2 rounded-md px-2 py-1.5 text-xs [&_svg]:size-3.5",
    hint: "text-[11px]",
    label: "text-xs",
  },
  md: {
    trigger: "h-10 gap-2 rounded-lg px-3 text-sm [&_svg]:size-4",
    list: "rounded-xl",
    option: "gap-2.5 rounded-lg px-2.5 py-2 text-sm [&_svg]:size-4",
    hint: "text-xs",
    label: "text-sm",
  },
  lg: {
    trigger: "h-12 gap-2.5 rounded-xl px-4 text-base [&_svg]:size-[18px]",
    list: "rounded-xl",
    option: "gap-3 rounded-lg px-3 py-2.5 text-base [&_svg]:size-[18px]",
    hint: "text-sm",
    label: "text-sm",
  },
} as const;

export interface SelectOption {
  value: string;
  label: string;
  /** Second line under the label, inside the list only. */
  description?: string;
  icon?: React.ReactNode;
  disabled?: boolean;
}

export interface SelectProps
  extends Omit<
    React.ButtonHTMLAttributes<HTMLButtonElement>,
    "color" | "value" | "defaultValue" | "onChange" | "children" | "type"
  > {
  options: SelectOption[];
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  placeholder?: React.ReactNode;
  /** Palette name or any hex color. Inherits `--neon-accent` when omitted. */
  color?: NeonColor;
  size?: keyof typeof sizes;
  /** Submits the value under this name with a surrounding form. */
  name?: string;
  label?: React.ReactNode;
  /** Helper text under the trigger. Hidden while `error` is set. */
  description?: React.ReactNode;
  /** Marks the select invalid and shows this message. Pass `true` for the red state alone. */
  error?: React.ReactNode;
  /** Classes for the trigger button. */
  className?: string;
  /** Classes for the outer block holding the label, trigger, list and message. */
  containerClassName?: string;
}

const Select = React.forwardRef<HTMLButtonElement, SelectProps>(
  (
    {
      options,
      value,
      defaultValue,
      onValueChange,
      placeholder = "Select…",
      color,
      size = "md",
      name,
      label,
      description,
      error,
      disabled,
      id: idProp,
      className,
      containerClassName,
      style,
      onClick,
      onKeyDown,
      onKeyUp,
      onMouseDown,
      onBlur,
      "aria-label": ariaLabel,
      "aria-labelledby": ariaLabelledBy,
      "aria-describedby": ariaDescribedBy,
      ...props
    },
    ref,
  ) => {
    const triggerRef = React.useRef<HTMLButtonElement>(null);
    const listRef = React.useRef<HTMLDivElement>(null);
    React.useImperativeHandle(ref, () => triggerRef.current as HTMLButtonElement);

    const autoId = React.useId();
    const id = idProp ?? autoId;
    const labelId = `${id}-label`;
    const listboxId = `${id}-listbox`;
    const optionId = (index: number) => `${id}-option-${index}`;

    const invalid = Boolean(error);
    const message = invalid ? (error === true ? null : error) : description;
    const messageId = message ? `${id}-${invalid ? "error" : "description"}` : undefined;
    const s = sizes[size];
    const reduceMotion = useReducedMotion();

    const [uncontrolledValue, setUncontrolledValue] = React.useState(defaultValue);
    const selectedValue = value !== undefined ? value : uncontrolledValue;
    const selectedIndex = options.findIndex((option) => option.value === selectedValue);
    const selected = selectedIndex >= 0 ? options[selectedIndex] : undefined;

    const [open, setOpen] = React.useState(false);
    const [listWidth, setListWidth] = React.useState<number>();
    const [activeIndex, setActiveIndex] = React.useState(-1);
    const isOpen = open && !disabled;

    const typeahead = React.useRef({ query: "", timer: 0 });
    // Only keyboard moves scroll the list; hover never yanks it under the cursor.
    const scrollToActive = React.useRef(false);

    React.useEffect(() => {
      const state = typeahead.current;
      return () => window.clearTimeout(state.timer);
    }, []);

    useIsomorphicLayoutEffect(() => {
      const list = listRef.current;
      if (!isOpen || !list || activeIndex < 0 || !scrollToActive.current) return;
      scrollToActive.current = false;
      const option = document.getElementById(`${id}-option-${activeIndex}`);
      if (!option) return;
      const top = option.offsetTop - 4;
      const bottom = option.offsetTop + option.offsetHeight + 4;
      if (top < list.scrollTop) list.scrollTop = top;
      else if (bottom > list.scrollTop + list.clientHeight) {
        list.scrollTop = bottom - list.clientHeight;
      }
    }, [isOpen, activeIndex, id]);

    function step(from: number, direction: 1 | -1) {
      for (let i = from; i >= 0 && i < options.length; i += direction) {
        if (!options[i].disabled) return i;
      }
      return -1;
    }

    function highlight(index: number) {
      if (index < 0) return;
      scrollToActive.current = true;
      setActiveIndex(index);
    }

    function openList(fallback: "first" | "last" = "first") {
      if (disabled) return;
      const index =
        selected && !selected.disabled
          ? selectedIndex
          : fallback === "first"
            ? step(0, 1)
            : step(options.length - 1, -1);
      scrollToActive.current = true;
      setActiveIndex(index);
      // Match the trigger, which may be narrower than the field's container.
      setListWidth(triggerRef.current?.offsetWidth);
      setOpen(true);
    }

    function close() {
      setOpen(false);
      typeahead.current.query = "";
    }

    function commit(index: number) {
      const option = options[index];
      if (option && !option.disabled && option.value !== selectedValue) {
        if (value === undefined) setUncontrolledValue(option.value);
        onValueChange?.(option.value);
      }
      close();
      triggerRef.current?.focus();
    }

    function matchTypeahead(char: string) {
      const state = typeahead.current;
      window.clearTimeout(state.timer);
      state.timer = window.setTimeout(() => {
        state.query = "";
      }, 500);
      state.query += char.toLowerCase();

      // Pressing the same letter repeatedly cycles through options that start with it.
      const repeated = state.query.split("").every((c) => c === state.query[0]);
      const query = repeated ? state.query[0] : state.query;
      const from = isOpen ? activeIndex : selectedIndex;
      const offset = repeated ? 1 : 0;
      for (let n = 0; n < options.length; n++) {
        const index = (((from + offset + n) % options.length) + options.length) % options.length;
        const option = options[index];
        if (!option.disabled && option.label.toLowerCase().startsWith(query)) return index;
      }
      return -1;
    }

    function handleKeyDown(event: React.KeyboardEvent<HTMLButtonElement>) {
      onKeyDown?.(event);
      if (event.defaultPrevented || disabled) return;
      const { key } = event;
      const printable = key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey;

      if (!isOpen) {
        if (key === "Enter" || key === " " || key === "ArrowDown") {
          event.preventDefault();
          openList("first");
        } else if (key === "ArrowUp") {
          event.preventDefault();
          openList("last");
        } else if (key === "Home" || key === "End") {
          event.preventDefault();
          highlight(key === "Home" ? step(0, 1) : step(options.length - 1, -1));
          setOpen(true);
        } else if (printable) {
          const index = matchTypeahead(key);
          if (index >= 0) {
            highlight(index);
            setOpen(true);
          }
        }
        return;
      }

      switch (key) {
        case "ArrowDown":
          event.preventDefault();
          highlight(step(activeIndex + 1, 1));
          break;
        case "ArrowUp":
          event.preventDefault();
          if (event.altKey) commit(activeIndex);
          else highlight(step(activeIndex - 1, -1));
          break;
        case "Home":
          event.preventDefault();
          highlight(step(0, 1));
          break;
        case "End":
          event.preventDefault();
          highlight(step(options.length - 1, -1));
          break;
        case "Enter":
          event.preventDefault();
          commit(activeIndex);
          break;
        case "Escape":
          event.preventDefault();
          event.stopPropagation();
          close();
          triggerRef.current?.focus();
          break;
        case "Tab":
          close();
          break;
        default:
          if (key === " " && !typeahead.current.query) {
            event.preventDefault();
            commit(activeIndex);
          } else if (printable) {
            event.preventDefault();
            highlight(matchTypeahead(key));
          }
      }
    }

    return (
      <div
        style={neonVars(color)}
        className={cn("[--neon:var(--neon-accent,34_211_238)]", "grid w-full gap-1.5", containerClassName)}
      >
        {label && (
          <label
            id={labelId}
            htmlFor={id}
            onClick={(event) => {
              // Focus the trigger like a native select label, without opening the list.
              event.preventDefault();
              triggerRef.current?.focus();
            }}
            className={cn("w-fit font-medium text-white/80", s.label)}
          >
            {label}
          </label>
        )}
        <div className="relative">
          <button
            ref={triggerRef}
            id={id}
            type="button"
            role="combobox"
            disabled={disabled}
            aria-haspopup="listbox"
            aria-expanded={isOpen}
            aria-controls={isOpen ? listboxId : undefined}
            aria-activedescendant={isOpen && activeIndex >= 0 ? optionId(activeIndex) : undefined}
            aria-label={ariaLabel}
            aria-labelledby={label ? labelId : ariaLabelledBy}
            aria-describedby={[ariaDescribedBy, messageId].filter(Boolean).join(" ") || undefined}
            aria-invalid={invalid || undefined}
            data-state={isOpen ? "open" : "closed"}
            data-placeholder={selected ? undefined : ""}
            style={invalid ? neonVars(INVALID, style) : style}
            className={cn(
              "flex w-full cursor-pointer select-none items-center border border-white/10 bg-white/[0.03] text-left text-white/90 outline-none",
              "transition-[border-color,background-color,box-shadow] ease-[cubic-bezier(0.23,1,0.32,1)] duration-[150ms,150ms,320ms]",
              "focus-visible:border-[rgb(var(--neon)/0.8)] focus-visible:bg-[rgb(var(--neon)/0.04)] focus-visible:shadow-[0_0_0_1px_rgb(var(--neon)/0.2),0_0_20px_-4px_rgb(var(--neon)/0.5),inset_0_0_12px_-2px_rgb(var(--neon)/0.14)]",
              "disabled:cursor-not-allowed disabled:opacity-50",
              "[&_svg]:pointer-events-none [&_svg]:shrink-0",
              !isOpen && "hover:border-white/20",
              invalid && "border-[rgb(var(--neon)/0.55)]",
              invalid && !isOpen && "hover:border-[rgb(var(--neon)/0.75)]",
              isOpen &&
                "border-[rgb(var(--neon)/0.8)] bg-[rgb(var(--neon)/0.04)] shadow-[0_0_0_1px_rgb(var(--neon)/0.2),0_0_20px_-4px_rgb(var(--neon)/0.5),inset_0_0_12px_-2px_rgb(var(--neon)/0.14)]",
              s.trigger,
              className,
            )}
            {...props}
            onMouseDown={(event) => {
              onMouseDown?.(event);
              // Focus is set on click instead, so Safari (which never focuses buttons) behaves.
              if (event.button === 0) event.preventDefault();
            }}
            onClick={(event) => {
              onClick?.(event);
              if (event.defaultPrevented) return;
              triggerRef.current?.focus();
              if (isOpen) close();
              else openList("first");
            }}
            onKeyDown={handleKeyDown}
            onKeyUp={(event) => {
              onKeyUp?.(event);
              // Space is handled on keydown; cancel the native click it would fire on keyup.
              if (event.key === " ") event.preventDefault();
            }}
            onBlur={(event) => {
              onBlur?.(event);
              close();
            }}
          >
            {selected?.icon && (
              <span className="flex shrink-0 items-center text-white/50">{selected.icon}</span>
            )}
            <span className={cn("min-w-0 flex-1 truncate", !selected && "text-white/30")}>
              {selected ? selected.label : placeholder}
            </span>
            <ChevronDown
              aria-hidden
              className={cn(
                "text-white/40 transition-[transform,color] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none",
                isOpen && "rotate-180 text-[rgb(var(--neon))]",
              )}
            />
          </button>

          <AnimatePresence>
            {isOpen && (
              <motion.div
                ref={listRef}
                id={listboxId}
                role="listbox"
                aria-labelledby={label ? labelId : ariaLabelledBy}
                aria-label={label ? undefined : ariaLabel}
                initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: -4, scale: 0.98 }}
                animate={{ opacity: 1, y: 0, scale: 1 }}
                exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: -4, scale: 0.98 }}
                transition={{ duration: 0.16, ease: EASE_OUT }}
                // Keep focus on the trigger so aria-activedescendant stays in charge.
                onMouseDown={(event) => event.preventDefault()}
                style={{ width: listWidth }}
                className={cn(
                  "absolute left-0 top-full z-50 w-full mt-1.5 max-h-64 origin-top overflow-y-auto overscroll-contain border border-white/10 bg-[#0a0b11] p-1",
                  "shadow-[0_18px_48px_-12px_rgb(0_0_0/0.85),0_0_0_1px_rgb(var(--neon)/0.06),0_0_28px_-10px_rgb(var(--neon)/0.45)]",
                  "[scrollbar-color:rgb(255_255_255/0.15)_transparent] [scrollbar-width:thin]",
                  s.list,
                )}
              >
                {options.map((option, index) => {
                  const active = index === activeIndex;
                  const isSelected = index === selectedIndex;
                  return (
                    <div
                      key={option.value}
                      id={optionId(index)}
                      role="option"
                      aria-selected={isSelected}
                      aria-disabled={option.disabled || undefined}
                      data-active={active || undefined}
                      onPointerMove={() => {
                        if (!option.disabled && !active) setActiveIndex(index);
                      }}
                      onClick={() => {
                        if (!option.disabled) commit(index);
                      }}
                      className={cn(
                        "flex cursor-pointer select-none items-center text-white/75 transition-colors duration-100",
                        s.option,
                        active && "bg-[rgb(var(--neon)/0.1)] text-white",
                        isSelected && "text-[rgb(var(--neon))]",
                        option.disabled && "cursor-not-allowed opacity-40",
                      )}
                    >
                      {option.icon && (
                        <span
                          className={cn(
                            "flex shrink-0 items-center",
                            isSelected ? "text-[rgb(var(--neon))]" : "text-white/45",
                          )}
                        >
                          {option.icon}
                        </span>
                      )}
                      <span className="flex min-w-0 flex-1 flex-col">
                        <span className="truncate">{option.label}</span>
                        {option.description && (
                          <span
                            className={cn(
                              "truncate",
                              s.hint,
                              active ? "text-white/55" : "text-white/40",
                            )}
                          >
                            {option.description}
                          </span>
                        )}
                      </span>
                      <Check
                        aria-hidden
                        className={cn(
                          "shrink-0 text-[rgb(var(--neon))] [filter:drop-shadow(0_0_6px_rgb(var(--neon)/0.7))]",
                          !isSelected && "invisible",
                        )}
                      />
                    </div>
                  );
                })}
              </motion.div>
            )}
          </AnimatePresence>
        </div>
        {name && <input type="hidden" name={name} value={selectedValue ?? ""} disabled={disabled} />}
        {message && (
          <p
            id={messageId}
            className={cn("text-xs leading-5", invalid ? "text-[#ff4d5e]" : "text-white/55")}
          >
            {message}
          </p>
        )}
      </div>
    );
  },
);
Select.displayName = "Select";

export { Select };
```
