Combobox
Autocomplete input and command palette with a list of suggestions.
The Combobox component is an autocomplete input that allows users to search and select from a list of options. It features bold borders and shadows that match the Things design system. Built from scratch using React and native HTML elements. No dependencies on any UI library.
Code
TypeScript: Copy this code into components/ui/combobox.tsx:
tsx
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import { Input } from "./input"
interface ComboboxContextValue {
open: boolean
setOpen: (open: boolean) => void
value: string
setValue: (value: string) => void
searchValue: string
setSearchValue: (value: string) => void
options: ComboboxOption[]
filteredOptions: ComboboxOption[]
selectedIndex: number
setSelectedIndex: (index: number) => void
}
const ComboboxContext = React.createContext<ComboboxContextValue | undefined>(undefined)
const useCombobox = () => {
const context = React.useContext(ComboboxContext)
if (!context) {
throw new Error("Combobox components must be used within Combobox")
}
return context
}
export interface ComboboxOption {
value: string
label: string
}
interface ComboboxProps {
value?: string
defaultValue?: string
onValueChange?: (value: string) => void
options: ComboboxOption[]
placeholder?: string
searchPlaceholder?: string
disabled?: boolean
children: React.ReactNode
className?: string
}
const Combobox = React.forwardRef<HTMLDivElement, ComboboxProps>(
({ value: controlledValue, defaultValue = "", onValueChange, options, placeholder = "Select...", searchPlaceholder = "Search...", disabled = false, children, className, ...props }, ref) => {
const [uncontrolledValue, setUncontrolledValue] = React.useState(defaultValue)
const [open, setOpen] = React.useState(false)
const [searchValue, setSearchValue] = React.useState("")
const [selectedIndex, setSelectedIndex] = React.useState(-1)
const containerRef = React.useRef<HTMLDivElement>(null)
const listRef = React.useRef<HTMLDivElement>(null)
const isControlled = controlledValue !== undefined
const value = isControlled ? controlledValue : uncontrolledValue
const filteredOptions = React.useMemo(() => {
if (!searchValue) return options
const search = searchValue.toLowerCase()
return options.filter(option =>
option.label.toLowerCase().includes(search) ||
option.value.toLowerCase().includes(search)
)
}, [options, searchValue])
const handleValueChange = React.useCallback((newValue: string) => {
if (disabled) return
if (!isControlled) {
setUncontrolledValue(newValue)
}
onValueChange?.(newValue)
setOpen(false)
setSearchValue("")
setSelectedIndex(-1)
}, [disabled, isControlled, onValueChange])
React.useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setOpen(false)
setSearchValue("")
setSelectedIndex(-1)
}
}
if (open) {
document.addEventListener("mousedown", handleClickOutside)
return () => document.removeEventListener("mousedown", handleClickOutside)
}
}, [open])
React.useEffect(() => {
if (!open) return
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "ArrowDown") {
e.preventDefault()
setSelectedIndex(prev =>
prev < filteredOptions.length - 1 ? prev + 1 : prev
)
} else if (e.key === "ArrowUp") {
e.preventDefault()
setSelectedIndex(prev => prev > 0 ? prev - 1 : -1)
} else if (e.key === "Enter" && selectedIndex >= 0) {
e.preventDefault()
handleValueChange(filteredOptions[selectedIndex].value)
} else if (e.key === "Escape") {
e.preventDefault()
setOpen(false)
setSearchValue("")
setSelectedIndex(-1)
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [open, filteredOptions, selectedIndex, handleValueChange])
React.useEffect(() => {
if (selectedIndex >= 0 && listRef.current) {
const selectedElement = listRef.current.children[selectedIndex] as HTMLElement
if (selectedElement) {
selectedElement.scrollIntoView({ block: "nearest" })
}
}
}, [selectedIndex])
return (
<ComboboxContext.Provider value={{
open,
setOpen,
value,
setValue: handleValueChange,
searchValue,
setSearchValue,
options,
filteredOptions,
selectedIndex,
setSelectedIndex,
}}>
<div ref={containerRef} className={cn("relative w-full", className)} {...props}>
{children}
</div>
</ComboboxContext.Provider>
)
}
)
Combobox.displayName = "Combobox"
interface ComboboxTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {}
const ComboboxTrigger = React.forwardRef<HTMLButtonElement, ComboboxTriggerProps>(
({ className, children, ...props }, ref) => {
const { open, setOpen, value, options, placeholder } = useCombobox()
const selectedOption = options.find(opt => opt.value === value)
return (
<button
ref={ref}
type="button"
onClick={() => setOpen(!open)}
className={cn(
"flex w-full items-center justify-between h-10 rounded-md border-2 border-foreground bg-background text-foreground px-3 py-2 text-sm font-bold transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 neobrutalism-shadow",
className
)}
{...props}
>
<span className="truncate">
{children || (selectedOption ? selectedOption.label : placeholder)}
</span>
<svg
className={cn(
"h-4 w-4 shrink-0 ml-2 transition-transform duration-200",
open && "rotate-180"
)}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
)
}
)
ComboboxTrigger.displayName = "ComboboxTrigger"
interface ComboboxContentProps extends React.HTMLAttributes<HTMLDivElement> {}
const ComboboxContent = React.forwardRef<HTMLDivElement, ComboboxContentProps>(
({ className, children, ...props }, ref) => {
const { open } = useCombobox()
if (!open) return null
return (
<div
ref={ref}
className={cn(
"absolute z-50 w-full mt-2 border-2 border-foreground bg-background rounded-lg neobrutalism-shadow overflow-hidden",
className
)}
{...props}
>
{children}
</div>
)
}
)
ComboboxContent.displayName = "ComboboxContent"
interface ComboboxInputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
const ComboboxInput = React.forwardRef<HTMLInputElement, ComboboxInputProps>(
({ className, ...props }, ref) => {
const { searchValue, setSearchValue, setOpen } = useCombobox()
return (
<div className="p-2 border-b-2 border-foreground">
<Input
ref={ref}
value={searchValue}
onChange={(e) => {
setSearchValue(e.target.value)
setOpen(true)
}}
className={className}
{...props}
/>
</div>
)
}
)
ComboboxInput.displayName = "ComboboxInput"
interface ComboboxListProps extends React.HTMLAttributes<HTMLDivElement> {}
const ComboboxList = React.forwardRef<HTMLDivElement, ComboboxListProps>(
({ className, ...props }, ref) => {
const { filteredOptions, value, setValue, selectedIndex, setSelectedIndex } = useCombobox()
const listRef = React.useRef<HTMLDivElement>(null)
React.useImperativeHandle(ref, () => listRef.current as HTMLDivElement)
if (filteredOptions.length === 0) {
return (
<div className="p-4 text-sm text-muted-foreground text-center">
No results found.
</div>
)
}
return (
<div
ref={listRef}
className={cn("max-h-[300px] overflow-y-auto", className)}
{...props}
>
{filteredOptions.map((option, index) => {
const isSelected = value === option.value
const isHighlighted = selectedIndex === index
return (
<button
key={option.value}
type="button"
onClick={() => setValue(option.value)}
className={cn(
"w-full text-left px-4 py-2 text-sm font-bold transition-colors",
isSelected
? "bg-accent text-accent-foreground border-l-4 border-l-foreground"
: isHighlighted
? "bg-muted"
: "hover:bg-muted",
className
)}
onMouseEnter={() => setSelectedIndex(index)}
>
{option.label}
</button>
)
})}
</div>
)
}
)
ComboboxList.displayName = "ComboboxList"
export { Combobox, ComboboxTrigger, ComboboxContent, ComboboxInput, ComboboxList }JavaScript: Copy this code into components/ui/combobox.jsx:
jsx
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import { Input } from "./input"
const ComboboxContext = React.createContext(undefined)
const useCombobox = () => {
const context = React.useContext(ComboboxContext)
if (!context) {
throw new Error("Combobox components must be used within Combobox")
}
return context
}
const Combobox = React.forwardRef(
({ value: controlledValue, defaultValue = "", onValueChange, options, placeholder = "Select...", searchPlaceholder = "Search...", disabled = false, children, className, ...props }, ref) => {
const [uncontrolledValue, setUncontrolledValue] = React.useState(defaultValue)
const [open, setOpen] = React.useState(false)
const [searchValue, setSearchValue] = React.useState("")
const [selectedIndex, setSelectedIndex] = React.useState(-1)
const containerRef = React.useRef(null)
const listRef = React.useRef(null)
const isControlled = controlledValue !== undefined
const value = isControlled ? controlledValue : uncontrolledValue
const filteredOptions = React.useMemo(() => {
if (!searchValue) return options
const search = searchValue.toLowerCase()
return options.filter(option =>
option.label.toLowerCase().includes(search) ||
option.value.toLowerCase().includes(search)
)
}, [options, searchValue])
const handleValueChange = React.useCallback((newValue) => {
if (disabled) return
if (!isControlled) {
setUncontrolledValue(newValue)
}
onValueChange?.(newValue)
setOpen(false)
setSearchValue("")
setSelectedIndex(-1)
}, [disabled, isControlled, onValueChange])
React.useEffect(() => {
const handleClickOutside = (event) => {
if (containerRef.current && !containerRef.current.contains(event.target)) {
setOpen(false)
setSearchValue("")
setSelectedIndex(-1)
}
}
if (open) {
document.addEventListener("mousedown", handleClickOutside)
return () => document.removeEventListener("mousedown", handleClickOutside)
}
}, [open])
React.useEffect(() => {
if (!open) return
const handleKeyDown = (e) => {
if (e.key === "ArrowDown") {
e.preventDefault()
setSelectedIndex(prev =>
prev < filteredOptions.length - 1 ? prev + 1 : prev
)
} else if (e.key === "ArrowUp") {
e.preventDefault()
setSelectedIndex(prev => prev > 0 ? prev - 1 : -1)
} else if (e.key === "Enter" && selectedIndex >= 0) {
e.preventDefault()
handleValueChange(filteredOptions[selectedIndex].value)
} else if (e.key === "Escape") {
e.preventDefault()
setOpen(false)
setSearchValue("")
setSelectedIndex(-1)
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [open, filteredOptions, selectedIndex, handleValueChange])
React.useEffect(() => {
if (selectedIndex >= 0 && listRef.current) {
const selectedElement = listRef.current.children[selectedIndex]
if (selectedElement) {
selectedElement.scrollIntoView({ block: "nearest" })
}
}
}, [selectedIndex])
return (
<ComboboxContext.Provider value={{
open,
setOpen,
value,
setValue: handleValueChange,
searchValue,
setSearchValue,
options,
filteredOptions,
selectedIndex,
setSelectedIndex,
placeholder,
searchPlaceholder,
}}>
<div ref={containerRef} className={cn("relative w-full", className)} {...props}>
{children}
</div>
</ComboboxContext.Provider>
)
}
)
Combobox.displayName = "Combobox"
const ComboboxTrigger = React.forwardRef(
({ className, children, ...props }, ref) => {
const { open, setOpen, value, options, placeholder } = useCombobox()
const selectedOption = options.find(opt => opt.value === value)
return (
<button
ref={ref}
type="button"
onClick={() => setOpen(!open)}
className={cn(
"flex w-full items-center justify-between h-10 rounded-md border-2 border-foreground bg-background text-foreground px-3 py-2 text-sm font-bold transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 neobrutalism-shadow",
className
)}
{...props}
>
<span className="truncate">
{children || (selectedOption ? selectedOption.label : placeholder)}
</span>
<svg
className={cn(
"h-4 w-4 shrink-0 ml-2 transition-transform duration-200",
open && "rotate-180"
)}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
)
}
)
ComboboxTrigger.displayName = "ComboboxTrigger"
const ComboboxContent = React.forwardRef(
({ className, children, ...props }, ref) => {
const { open } = useCombobox()
if (!open) return null
return (
<div
ref={ref}
className={cn(
"absolute z-50 w-full mt-2 border-2 border-foreground bg-background rounded-lg neobrutalism-shadow overflow-hidden",
className
)}
{...props}
>
{children}
</div>
)
}
)
ComboboxContent.displayName = "ComboboxContent"
const ComboboxInput = React.forwardRef(
({ className, placeholder, ...props }, ref) => {
const { searchValue, setSearchValue, setOpen, searchPlaceholder } = useCombobox()
return (
<div className="p-2 border-b-2 border-foreground">
<Input
ref={ref}
value={searchValue}
onChange={(e) => {
setSearchValue(e.target.value)
setOpen(true)
}}
placeholder={placeholder || searchPlaceholder}
className={className}
{...props}
/>
</div>
)
}
)
ComboboxInput.displayName = "ComboboxInput"
const ComboboxList = React.forwardRef(
({ className, ...props }, ref) => {
const { filteredOptions, value, setValue, selectedIndex, setSelectedIndex } = useCombobox()
const listRef = React.useRef(null)
React.useImperativeHandle(ref, () => listRef.current)
if (filteredOptions.length === 0) {
return (
<div className="p-4 text-sm text-muted-foreground text-center">
No results found.
</div>
)
}
return (
<div
ref={listRef}
className={cn("max-h-[300px] overflow-y-auto", className)}
{...props}
>
{filteredOptions.map((option, index) => {
const isSelected = value === option.value
const isHighlighted = selectedIndex === index
return (
<button
key={option.value}
type="button"
onClick={() => setValue(option.value)}
className={cn(
"w-full text-left px-4 py-2 text-sm font-bold transition-colors",
isSelected
? "bg-accent text-accent-foreground border-l-2 border-foreground"
: isHighlighted
? "bg-muted"
: "hover:bg-muted",
className
)}
onMouseEnter={() => setSelectedIndex(index)}
>
{option.label}
</button>
)
})}
</div>
)
}
)
ComboboxList.displayName = "ComboboxList"
export { Combobox, ComboboxTrigger, ComboboxContent, ComboboxInput, ComboboxList }Usage
TypeScript:
tsx
import {
Combobox,
ComboboxTrigger,
ComboboxContent,
ComboboxInput,
ComboboxList,
} from "@/components/ui/combobox"
const frameworks = [
{ value: "nextjs", label: "Next.js" },
{ value: "sveltekit", label: "SvelteKit" },
{ value: "nuxtjs", label: "Nuxt.js" },
{ value: "remix", label: "Remix" },
{ value: "astro", label: "Astro" },
]
function MyComponent() {
return (
<Combobox options={frameworks} placeholder="Select framework...">
<ComboboxTrigger />
<ComboboxContent>
<ComboboxInput placeholder="Search framework..." />
<ComboboxList />
</ComboboxContent>
</Combobox>
)
}JavaScript:
jsx
import {
Combobox,
ComboboxTrigger,
ComboboxContent,
ComboboxInput,
ComboboxList,
} from "@/components/ui/combobox"
const frameworks = [
{ value: "nextjs", label: "Next.js" },
{ value: "sveltekit", label: "SvelteKit" },
{ value: "nuxtjs", label: "Nuxt.js" },
{ value: "remix", label: "Remix" },
{ value: "astro", label: "Astro" },
]
function MyComponent() {
return (
<Combobox options={frameworks} placeholder="Select framework...">
<ComboboxTrigger />
<ComboboxContent>
<ComboboxInput placeholder="Search framework..." />
<ComboboxList />
</ComboboxContent>
</Combobox>
)
}Make sure you also have the lib/utils.ts file with the cn helper function, and thecomponents/ui/input.tsx component.