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 autocompleteOr install the full npm package:
npm install bigbulluiUsage
Open in StackBlitzimport { 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.tsxZero 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
| Prop | Type | Description |
|---|---|---|
| options | AutocompleteOption[] | |
| value? | string | |
| defaultValue? | string | |
| onValueChange? | (val: string) => void | |
| className? | string |