# Select

Listbox select that unfolds from its trigger with staggered options, full keyboard support and type-ahead.

**Category:** Inputs  
**Exports:** `Select`  
**npm dependencies:** motion (+ clsx, tailwind-merge for `cn`)

## Install

```bash
npx shadcn@latest add https://charcoalui.com/r/select.json
```
The file lands at `components/ui/select.tsx`; import from `@/components/ui/select`.

## API — Select
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `options` | `{ value: string; label: string; icon?: ReactNode }[]` | — |  |
| `value` | `string` | — | Controlled value. |
| `defaultValue` | `string` | — | Initial value when uncontrolled. |
| `onValueChange` | `(value: string) => void` | — |  |
| `placeholder` | `string` | `"Select…"` |  |
| `label` | `string` | — |  |
| `disabled` | `boolean` | — |  |

## Example — Select

```tsx
import { Globe } from "lucide-react";
import { Select } from "@/components/ui/select";

const zones = ["London", "Dubai", "Karachi", "Singapore", "Tokyo", "Sydney", "New York", "San Francisco"];

export default function SelectDemo({
  label = "Time zone",
  placeholder = "Choose a city",
  disabled = false,
}: {
  label?: string;
  placeholder?: string;
  disabled?: boolean;
}) {
  return (
    <div className="w-full max-w-xs pb-48">
      <Select
        label={label}
        placeholder={placeholder}
        disabled={disabled}
        defaultValue="Dubai"
        options={zones.map((z) => ({ value: z, label: z, icon: <Globe /> }))}
      />
    </div>
  );
}
```

## Source — components/ui/select.tsx

```tsx
"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

export type SelectOption = { value: string; label: string; icon?: React.ReactNode };

export type SelectProps = {
  options: SelectOption[];
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  placeholder?: string;
  label?: string;
  disabled?: boolean;
  className?: string;
};

export function Select({ options, value: valueProp, defaultValue, onValueChange, placeholder = "Select…", label, disabled, className }: SelectProps) {
  const id = React.useId();
  const reduce = useReducedMotion();
  const [inner, setInner] = React.useState(defaultValue);
  const value = valueProp ?? inner;
  const [open, setOpen] = React.useState(false);
  const [active, setActive] = React.useState(0);
  const rootRef = React.useRef<HTMLDivElement>(null);
  const triggerRef = React.useRef<HTMLButtonElement>(null);
  const listRef = React.useRef<HTMLDivElement>(null);
  const typed = React.useRef({ text: "", at: 0 });
  const selected = options.find((o) => o.value === value);

  const show = () => {
    setActive(Math.max(0, options.findIndex((o) => o.value === value)));
    setOpen(true);
  };
  const close = (refocus = true) => {
    setOpen(false);
    if (refocus) triggerRef.current?.focus();
  };
  const choose = (o: SelectOption) => {
    if (valueProp === undefined) setInner(o.value);
    onValueChange?.(o.value);
    close();
  };

  React.useEffect(() => {
    if (!open) return;
    listRef.current?.focus();
    const onDown = (e: PointerEvent) => !rootRef.current?.contains(e.target as Node) && close(false);
    document.addEventListener("pointerdown", onDown);
    return () => document.removeEventListener("pointerdown", onDown);
  }, [open]);

  React.useEffect(() => {
    if (open) listRef.current?.querySelector(`[data-i="${active}"]`)?.scrollIntoView({ block: "nearest" });
  }, [active, open]);

  const onListKey = (e: React.KeyboardEvent) => {
    if (e.key === "ArrowDown") setActive((a) => Math.min(options.length - 1, a + 1));
    else if (e.key === "ArrowUp") setActive((a) => Math.max(0, a - 1));
    else if (e.key === "Home") setActive(0);
    else if (e.key === "End") setActive(options.length - 1);
    else if (e.key === "Enter" || e.key === " ") options[active] && choose(options[active]);
    else if (e.key === "Escape" || e.key === "Tab") return e.key === "Escape" ? (e.preventDefault(), close()) : close(false);
    else if (e.key.length === 1) {
      // Type-ahead: jump to the first option starting with what was typed recently.
      const now = Date.now();
      typed.current.text = (now - typed.current.at > 600 ? "" : typed.current.text) + e.key.toLowerCase();
      typed.current.at = now;
      const i = options.findIndex((o) => o.label.toLowerCase().startsWith(typed.current.text));
      if (i >= 0) setActive(i);
    } else return;
    e.preventDefault();
  };

  return (
    <div ref={rootRef} className={cn("relative grid w-full gap-1.5", className)}>
      {label && (
        <span id={`${id}-label`} className="text-sm font-medium">
          {label}
        </span>
      )}
      <button
        ref={triggerRef}
        type="button"
        disabled={disabled}
        aria-haspopup="listbox"
        aria-expanded={open}
        aria-controls={`${id}-list`}
        aria-labelledby={label ? `${id}-label ${id}-value` : undefined}
        onClick={() => (open ? close() : show())}
        onKeyDown={(e) => {
          if (["ArrowDown", "ArrowUp", "Enter", " "].includes(e.key)) {
            e.preventDefault();
            show();
          }
        }}
        className={cn(
          "flex h-11 w-full cursor-pointer items-center gap-2 rounded-lg border border-input bg-background px-3.5 text-left text-sm outline-none transition-[border-color,box-shadow]",
          "focus-visible:border-foreground/40 focus-visible:ring-[3px] focus-visible:ring-ring/25 disabled:cursor-not-allowed disabled:opacity-50",
          open && "border-foreground/40",
        )}
      >
        {selected?.icon && <span className="text-muted-foreground [&_svg]:size-4">{selected.icon}</span>}
        <span id={`${id}-value`} className={cn("flex-1 truncate", !selected && "text-muted-foreground")}>
          {selected?.label ?? placeholder}
        </span>
        <motion.svg
          viewBox="0 0 24 24"
          className="size-4 text-muted-foreground"
          fill="none"
          stroke="currentColor"
          strokeWidth="2"
          strokeLinecap="round"
          strokeLinejoin="round"
          animate={{ rotate: open ? 180 : 0 }}
          transition={{ type: "spring", stiffness: 500, damping: 30 }}
          aria-hidden
        >
          <path d="m6 9 6 6 6-6" />
        </motion.svg>
      </button>
      <AnimatePresence>
        {open && (
          <motion.div
            ref={listRef}
            id={`${id}-list`}
            role="listbox"
            tabIndex={-1}
            aria-labelledby={label ? `${id}-label` : undefined}
            aria-activedescendant={`${id}-opt-${active}`}
            onKeyDown={onListKey}
            initial={{ opacity: 0, y: reduce ? 0 : -6, scale: reduce ? 1 : 0.97 }}
            animate={{ opacity: 1, y: 0, scale: 1 }}
            exit={{ opacity: 0, y: reduce ? 0 : -4, scale: reduce ? 1 : 0.98, transition: { duration: 0.12 } }}
            transition={{ type: "spring", stiffness: 600, damping: 36 }}
            className="absolute inset-x-0 top-full z-50 mt-2 max-h-64 origin-top overflow-y-auto rounded-xl border bg-popover p-1 text-popover-foreground shadow-[0_16px_40px_-12px_rgb(0_0_0/0.35)] outline-none"
          >
            {options.map((o, i) => {
              const isSel = o.value === value;
              return (
                <motion.div
                  key={o.value}
                  id={`${id}-opt-${i}`}
                  data-i={i}
                  role="option"
                  aria-selected={isSel}
                  onPointerMove={() => setActive(i)}
                  onClick={() => choose(o)}
                  initial={{ opacity: 0, x: reduce ? 0 : -6 }}
                  animate={{ opacity: 1, x: 0 }}
                  transition={{ delay: reduce ? 0 : Math.min(i, 8) * 0.025, type: "spring", stiffness: 600, damping: 36 }}
                  className="relative flex h-9 cursor-pointer items-center gap-2 rounded-lg px-2.5 text-sm"
                >
                  {i === active && (
                    <motion.span layoutId={`${id}-hl`} className="absolute inset-0 rounded-lg bg-accent" transition={{ type: "spring", stiffness: 700, damping: 45 }} />
                  )}
                  {o.icon && <span className="relative z-10 text-muted-foreground [&_svg]:size-4">{o.icon}</span>}
                  <span className="relative z-10 flex-1 truncate">{o.label}</span>
                  {isSel && (
                    <svg viewBox="0 0 24 24" className="relative z-10 size-4" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
                      <path d="M5 12.5l4.5 4.5L19 7" />
                    </svg>
                  )}
                </motion.div>
              );
            })}
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}
```