Combobox
ComponentsAn autocomplete input and searchable dropdown menu with typeahead filtering and keyboard navigation.
Searchable Combobox
Combines the functionality of a text input and a select dropdown. Allows users to quickly filter large option sets by typing while preserving full arrow-key and enter-key keyboard traversal.
Live Functional Playground Interactive Hooks • useState
Next.js✓
Astro
React 19
Vue 3
Svelte 5
Tailwind CSS
Live state: Next.js
Motion Physics & Animation Showcase Framer Motion ✨
Motion Physics
✨
Hover to trigger spring elevation lift
Format:
Required Animation Library: framer-motion & lucide-react
Install this package in your project before importing the animated component.
npm i framer-motion lucide-react <div class="relative w-full max-w-xs">
<div class="relative">
<input type="text" placeholder="Search framework..." value="Next.js" class="w-full px-3.5 py-2.5 pl-9 pr-8 text-xs sm:text-sm rounded-xl border border-input bg-background text-foreground shadow-2xs focus:border-primary focus:ring-2 focus:ring-primary/20 outline-none transition-all" />
<svg class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
<svg class="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6"/></svg>
</div>
</div> import React from "react";
export function Combobox({ options = ["Next.js", "Astro", "React", "Vue", "Svelte"] }: { options?: string[] }) {
return (
<div className="relative w-full max-w-xs">
<input
type="text"
placeholder="Select framework..."
className="w-full px-3.5 py-2.5 text-xs sm:text-sm rounded-xl border border-input bg-background text-foreground shadow-2xs focus:border-primary focus:ring-2 focus:ring-primary/20 outline-none transition-all"
/>
</div>
);
} "use client";
import React, { useState, useRef, useEffect } from "react";
export interface ComboboxOption {
value: string;
label: string;
category?: string;
}
const defaultOptions: ComboboxOption[] = [
{ value: "next", label: "Next.js", category: "React Framework" },
{ value: "astro", label: "Astro", category: "Content-Driven" },
{ value: "react", label: "React", category: "UI Library" },
{ value: "vue", label: "Vue.js", category: "Progressive Framework" },
{ value: "svelte", label: "Svelte", category: "Compiler" },
{ value: "tailwind", label: "Tailwind CSS", category: "Styling Engine" },
];
export interface ComboboxProps {
options?: ComboboxOption[];
placeholder?: string;
defaultValue?: string;
onChange?: (val: string) => void;
}
export default function Combobox({
options = defaultOptions,
placeholder = "Select technology...",
defaultValue = "",
onChange,
}: ComboboxProps) {
const [isOpen, setIsOpen] = useState(false);
const [query, setQuery] = useState("");
const [selected, setSelected] = useState<ComboboxOption | null>(
options.find((o) => o.value === defaultValue) || null
);
const [highlightedIndex, setHighlightedIndex] = useState(0);
const containerRef = useRef<HTMLDivElement>(null);
const filteredOptions = options.filter(
(opt) =>
opt.label.toLowerCase().includes(query.toLowerCase()) ||
opt.category?.toLowerCase().includes(query.toLowerCase())
);
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: ComboboxOption) => {
setSelected(option);
setQuery("");
setIsOpen(false);
onChange?.(option.value);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (!isOpen) {
if (e.key === "ArrowDown" || e.key === "Enter") {
setIsOpen(true);
}
return;
}
if (e.key === "ArrowDown") {
e.preventDefault();
setHighlightedIndex((prev) => (prev + 1) % filteredOptions.length);
} else if (e.key === "ArrowUp") {
e.preventDefault();
setHighlightedIndex((prev) => (prev - 1 + filteredOptions.length) % filteredOptions.length);
} else if (e.key === "Enter" && filteredOptions[highlightedIndex]) {
e.preventDefault();
handleSelect(filteredOptions[highlightedIndex]);
} else if (e.key === "Escape") {
setIsOpen(false);
}
};
return (
<div ref={containerRef} className="relative w-full max-w-sm">
<div className="relative">
<input
type="text"
value={isOpen ? query : selected ? selected.label : ""}
placeholder={selected ? selected.label : placeholder}
onFocus={() => setIsOpen(true)}
onChange={(e) => {
setQuery(e.target.value);
setIsOpen(true);
setHighlightedIndex(0);
}}
onKeyDown={handleKeyDown}
className="w-full px-4 py-2.5 pr-10 text-xs sm:text-sm rounded-xl border border-input bg-background text-foreground shadow-2xs focus:border-primary focus:ring-2 focus:ring-primary/20 outline-none transition-all placeholder:text-muted-foreground"
/>
<div className="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-1.5 text-muted-foreground">
{selected && (
<button
type="button"
onClick={() => {
setSelected(null);
setQuery("");
onChange?.("");
}}
className="hover:text-foreground p-0.5"
>
✕
</button>
)}
<svg className={`h-4 w-4 transition-transform duration-200 ${isOpen ? "rotate-180" : ""}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="m6 9 6 6 6-6" />
</svg>
</div>
</div>
{isOpen && (
<div className="absolute left-0 right-0 top-full mt-2 z-50 max-h-60 overflow-y-auto rounded-xl border border-border bg-card p-1 shadow-xl animate-in fade-in zoom-in-95 duration-150">
{filteredOptions.length === 0 ? (
<p className="p-3 text-xs text-center text-muted-foreground">No matches found</p>
) : (
filteredOptions.map((opt, idx) => {
const isHighlighted = idx === highlightedIndex;
const isChosen = selected?.value === opt.value;
return (
<div
key={opt.value}
onClick={() => handleSelect(opt)}
onMouseEnter={() => setHighlightedIndex(idx)}
className={`flex items-center justify-between px-3 py-2 text-xs rounded-lg cursor-pointer transition-colors ${
isHighlighted ? "bg-muted text-foreground" : "text-muted-foreground"
} ${isChosen ? "font-bold text-primary" : ""}`}
>
<div className="flex flex-col">
<span>{opt.label}</span>
{opt.category && (
<span className="text-[10px] text-muted-foreground font-normal">{opt.category}</span>
)}
</div>
{isChosen && <span className="text-primary font-bold">✓</span>}
</div>
);
})
)}
</div>
)}
</div>
);
} "use client";
import React, { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Search, ChevronDown, Check } from "lucide-react";
const frameworks = [
{ id: "react", name: "React 19", stars: "220k" },
{ id: "next", name: "Next.js 15", stars: "125k" },
{ id: "astro", name: "Astro 6", stars: "48k" },
{ id: "vue", name: "Vue 3", stars: "207k" },
{ id: "svelte", name: "Svelte 5", stars: "78k" },
];
export default function AnimatedCombobox() {
const [isOpen, setIsOpen] = useState(false);
const [selected, setSelected] = useState(frameworks[0]);
const [query, setQuery] = useState("");
const filtered = frameworks.filter((f) =>
f.name.toLowerCase().includes(query.toLowerCase())
);
return (
<div className="relative w-full max-w-sm">
<motion.div
whileTap={{ scale: 0.99 }}
onClick={() => setIsOpen(!isOpen)}
className="flex items-center justify-between px-4 py-2.5 rounded-xl border border-border bg-card/80 backdrop-blur cursor-pointer shadow-xs hover:border-primary/50 transition-colors"
>
<div className="flex items-center gap-2.5">
<Search className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium text-foreground">{selected.name}</span>
</div>
<motion.div animate={{ rotate: isOpen ? 180 : 0 }} transition={{ duration: 0.2 }}>
<ChevronDown className="h-4 w-4 text-muted-foreground" />
</motion.div>
</motion.div>
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0, y: -8, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -8, scale: 0.96 }}
transition={{ type: "spring", stiffness: 450, damping: 25 }}
className="absolute left-0 right-0 top-full mt-2 z-50 rounded-2xl border border-border bg-card/95 backdrop-blur-md p-2 shadow-2xl"
>
<input
type="text"
placeholder="Filter list..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="w-full px-3 py-1.5 mb-2 text-xs rounded-lg border border-border bg-background focus:outline-none focus:ring-1 focus:ring-primary"
/>
<div className="space-y-1">
{filtered.map((fw) => {
const isSelected = selected.id === fw.id;
return (
<motion.div
key={fw.id}
whileHover={{ x: 3 }}
onClick={() => {
setSelected(fw);
setIsOpen(false);
setQuery("");
}}
className={`flex items-center justify-between px-3 py-2 rounded-xl text-xs font-medium cursor-pointer transition-colors ${
isSelected ? "bg-primary text-primary-foreground font-semibold" : "hover:bg-muted text-foreground"
}`}
>
<span>{fw.name}</span>
<div className="flex items-center gap-2">
<span className={`text-[10px] ${isSelected ? "text-primary-foreground/80" : "text-muted-foreground"}`}>
★ {fw.stars}
</span>
{isSelected && <Check className="h-3.5 w-3.5" />}
</div>
</motion.div>
);
})}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
} Props Reference
| Prop | Type | Default | Description |
|---|---|---|---|
options | ComboboxOption[] | [...] | Array of item objects with value, label, and optional category |
placeholder | string | "Select technology..." | Placeholder text shown when no value is selected |
defaultValue | string | "" | Initial pre-selected item value |
onChange | (value: string) => void | undefined | Callback fired when an option is selected |
Keyboard Traversal Standards
- Arrow Down / Arrow Up: Moves highlight focus sequentially through filtered matches with boundary wrapping.
- Enter: Selects currently highlighted item and closes dropdown popup.
- Escape: Immediately closes the dropdown without modifying the currently selected value.
- Click Outside: Automatically dismisses the dropdown using an event listener ref.