Skip to content
All Taste components

Forms

Select & combobox

A native select for short lists, and a searchable combobox for long ones.

native select · combobox open · match highlight · selected check

  • BengaluruKarnataka
  • BelagaviKarnataka
  • BerhampurOdisha
"use client";
import { useId, useState } from "react";
import { Check, ChevronDown, Search } from "lucide-react";
import { cn } from "@/lib/utils";

const field =
  "rounded border border-border-strong bg-bg transition focus-within:border-primary focus-within:ring-[3px] focus-within:ring-primary/20";

export function Select({ label, options, ...props }:
  React.SelectHTMLAttributes<HTMLSelectElement> & { label: string; options: { value: string; label: string }[] }) {
  const id = useId();
  return (
    <div className="grid gap-1.5">
      <label htmlFor={id} className="text-[13px] font-medium text-fg">{label}</label>
      <div className={cn("relative", field)}>
        <select id={id} className="h-9 w-full appearance-none bg-transparent pl-3 pr-9 text-sm text-fg outline-none" {...props}>
          {options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
        </select>
        <ChevronDown className="pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 text-fg-subtle" aria-hidden />
      </div>
    </div>
  );
}

type Option = { value: string; label: string; hint?: string };

export function Combobox({ label, options, value, onChange, placeholder = "Search…" }:
  { label: string; options: Option[]; value: string | null; onChange: (value: string) => void; placeholder?: string }) {
  const id = useId();
  const [query, setQuery] = useState(options.find((o) => o.value === value)?.label ?? "");
  const [open, setOpen] = useState(false);
  const [active, setActive] = useState(0);
  const matches = options.filter((o) => o.label.toLowerCase().includes(query.trim().toLowerCase()));

  function pick(o: Option) {
    onChange(o.value);
    setQuery(o.label);
    setOpen(false);
  }

  function onKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
    if (e.key === "ArrowDown") { e.preventDefault(); setOpen(true); setActive((i) => Math.min(i + 1, matches.length - 1)); }
    else if (e.key === "ArrowUp") { e.preventDefault(); setActive((i) => Math.max(i - 1, 0)); }
    else if (e.key === "Enter" && open && matches[active]) { e.preventDefault(); pick(matches[active]); }
    else if (e.key === "Escape") setOpen(false);
  }

  return (
    <div className="relative grid gap-1.5">
      <label htmlFor={id} className="text-[13px] font-medium text-fg">{label}</label>
      <div className={cn("flex h-9 items-center gap-2 px-2.5", field)}>
        <Search className="size-4 shrink-0 text-fg-subtle" aria-hidden />
        <input
          id={id}
          role="combobox"
          aria-expanded={open}
          aria-controls={id + "-list"}
          aria-autocomplete="list"
          aria-activedescendant={open && matches[active] ? id + "-" + matches[active].value : undefined}
          value={query}
          placeholder={placeholder}
          onChange={(e) => { setQuery(e.target.value); setOpen(true); setActive(0); }}
          onFocus={() => setOpen(true)}
          onBlur={() => setOpen(false)}
          onKeyDown={onKeyDown}
          className="h-full min-w-0 flex-1 bg-transparent text-sm text-fg outline-none placeholder:text-fg-subtle"
        />
        <ChevronDown className="size-4 shrink-0 text-fg-subtle" aria-hidden />
      </div>
      {open && (
        <ul id={id + "-list"} role="listbox" aria-label={label}
          className="absolute inset-x-0 top-full z-10 mt-1 max-h-60 overflow-auto rounded-lg border border-border bg-bg p-1 shadow-lg">
          {matches.length === 0 && <li className="px-2 py-2 text-[13px] text-fg-subtle">No matches</li>}
          {matches.map((o, i) => (
            <li
              key={o.value}
              id={id + "-" + o.value}
              role="option"
              aria-selected={o.value === value}
              onMouseDown={(e) => { e.preventDefault(); pick(o); }}
              onMouseEnter={() => setActive(i)}
              className={cn(
                "flex min-h-[34px] cursor-pointer items-center gap-2 rounded px-2 text-sm text-fg",
                i === active && "bg-surface-2",
                o.value === value && "font-medium",
              )}
            >
              <span className="truncate">{o.label}</span>
              {o.hint && <span className="text-xs font-normal text-fg-subtle">{o.hint}</span>}
              <Check className={cn("ml-auto size-4 shrink-0 text-primary", o.value !== value && "invisible")} aria-hidden />
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

The code uses the Taste Tailwind preset. Make yours on the Taste page with Export my system.