Componentpickers1.63 KB gzipZero dependencies

Autocomplete

Typeahead search input with instant suggestions and keyboard select.

Preview

Live Preview

Installation

Tokens guide →

Add to your project via CLI (zero dependencies, code you own):

npx bigbullui add autocomplete

Or install the full npm package:

npm install bigbullui
import { Autocomplete } from "@/components/ui/autocomplete";

<Autocomplete options={[{ value: "1", label: "Orchestra" }]} />

Usage guidance

Use Autocomplete when a ticket stub surface needs Typeahead search input with instant suggestions and keyboard select. Keep props minimal and prefer composition with sibling stubs.

Avoid Autocomplete for long form flows or dense data grids where a dedicated layout block fits better.

Source code

autocomplete.tsx

Zero dependencies (only React + utils). Copy and paste directly into your project:

"use client";

import * as React from "react";
import { cn } from "./lib/utils";

export interface AutocompleteOption {
  value: string;
  label: string;
  category?: string;
}

export interface AutocompleteProps
  extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "onChange"> {
  options: AutocompleteOption[];
  value?: string;
  defaultValue?: string;
  onValueChange?: (val: string) => void;
  className?: string;
}

export function Autocomplete({
  options,
  value: controlledValue,
  defaultValue = "",
  onValueChange,
  placeholder = "Search seat or section...",
  className,
  ...props
}: AutocompleteProps) {
  const [internalVal, setInternalVal] = React.useState(defaultValue);
  const isControlled = controlledValue !== undefined;
  const currentVal = isControlled ? controlledValue : internalVal;

  const [isOpen, setIsOpen] = React.useState(false);
  const [selectedIndex, setSelectedIndex] = React.useState(0);
  const containerRef = React.useRef<HTMLDivElement>(null);

  const filteredOptions = React.useMemo(() => {
    if (!currentVal) return options;
    return options.filter((o) =>
      o.label.toLowerCase().includes(currentVal.toLowerCase()) ||
      o.value.toLowerCase().includes(currentVal.toLowerCase())
    );
  }, [options, currentVal]);

  React.useEffect(() => {
    const handleClickOutside = (e: MouseEvent) => {
      if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
        setIsOpen(false);
      }
    };
    document.addEventListener("mousedown", handleClickOutside);
    return () => document.removeEventListener("mousedown", handleClickOutside);
  }, []);

  const handleSelect = (option: AutocompleteOption) => {
    if (!isControlled) setInternalVal(option.label);
    onValueChange?.(option.value);
    setIsOpen(false);
  };

  const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
    if (!isOpen && (e.key === "ArrowDown" || e.key === "Enter")) {
      setIsOpen(true);
      return;
    }

    if (isOpen) {
      if (e.key === "ArrowDown") {
        e.preventDefault();
        setSelectedIndex((i) => (i < filteredOptions.length - 1 ? i + 1 : 0));
      } else if (e.key === "ArrowUp") {
        e.preventDefault();
        setSelectedIndex((i) => (i > 0 ? i - 1 : filteredOptions.length - 1));
      } else if (e.key === "Enter" && filteredOptions[selectedIndex]) {
        e.preventDefault();
        handleSelect(filteredOptions[selectedIndex]);
      } else if (e.key === "Escape") {
        setIsOpen(false);
      }
    }
  };

  return (
    <div ref={containerRef} className={cn("relative w-full max-w-sm font-mono select-none", className)}>
      <div className="relative flex items-center">
        <input
          type="text"
          value={currentVal}
          onChange={(e) => {
            if (!isControlled) setInternalVal(e.target.value);
            onValueChange?.(e.target.value);
            setIsOpen(true);
            setSelectedIndex(0);
          }}
          onFocus={() => setIsOpen(true)}
          onKeyDown={handleKeyDown}
          placeholder={placeholder}
          className={cn(
            "w-full rounded-md border-2 border-foreground bg-card px-3 py-2 text-xs text-foreground placeholder:text-muted-foreground outline-none transition-all shadow-xs",
            "focus:border-accent focus:ring-1 focus:ring-accent"
          )}
          {...props}
        />
        {currentVal && (
          <button
            type="button"
            onClick={() => {
              if (!isControlled) setInternalVal("");
              onValueChange?.("");
            }}
            className="absolute right-2.5 text-muted-foreground hover:text-foreground text-xs cursor-pointer font-bold"
            aria-label="Clear input"
          >
            ✕
          </button>
        )}
      </div>

      {isOpen && filteredOptions.length > 0 && (
        <div className="absolute left-0 top-full z-50 mt-1 max-h-56 w-full overflow-y-auto rounded-lg border-2 border-foreground bg-card p-1 shadow-xl outline-1 outline-dashed outline-offset-[-4px] animate-[scale-in_0.12s_ease-out_both] text-xs">
          {filteredOptions.map((opt, idx) => (
            <div
              key={opt.value}
              onClick={() => handleSelect(opt)}
              onMouseEnter={() => setSelectedIndex(idx)}
              className={cn(
                "flex cursor-pointer items-center justify-between rounded-sm px-2.5 py-1.5 transition-colors",
                idx === selectedIndex
                  ? "bg-accent font-bold text-accent-foreground"
                  : "text-foreground hover:bg-secondary"
              )}
            >
              <span>{opt.label}</span>
              {opt.category && (
                <span className="text-[10px] opacity-70 uppercase tracking-wider">
                  [{opt.category}]
                </span>
              )}
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

Props

PropTypeDescription
optionsAutocompleteOption[]
value?string
defaultValue?string
onValueChange?(val: string) => void
className?string