Autocomplete
A wrapper for Base UI Autocomplete with type-to-filter suggestions, grouped options, async search, inline completion mode, and clear/trigger controls.
Installation
npx shadcn@latest add @glrk-ui/autocompleteIf you haven't set up the prerequisites yet, check out Prerequest section.
Copy and paste the following code into your project.
type allowedPrimitiveT = string | number | boolean
type optionT = {
label: React.ReactNode
value: allowedPrimitiveT
className?: string
disabled?: boolean
}
type groupT = {
group: string
options: (allowedPrimitiveT | optionT)[]
className?: string
}
type optionsT = (allowedPrimitiveT | optionT | groupT)[]
type indicatorAtT = 'right' | 'left'
import { isValidElement, type ReactNode } from 'react'
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function isAllowedPrimitive(value: unknown): value is allowedPrimitiveT {
return ['string', 'number', 'boolean'].includes(typeof value)
}
export function parseAllowedPrimitive(value: allowedPrimitiveT): allowedPrimitiveT {
if (typeof value !== 'string') return value
const trimmed = value.trim()
if (trimmed === 'true') return true
if (trimmed === 'false') return false
if (/^-?\d+(\.\d+)?$/.test(trimmed)) return Number(trimmed)
return trimmed
}
export function optionTypeChecker<T>(key: keyof T) {
return (option: any): option is T => !!option && typeof option === 'object' && key in option
}
export const isSeparator = (item: any) => item === '---'
export const isOption = optionTypeChecker<optionT>('value')
export const isGroup = optionTypeChecker<groupT>('group')
export const getValue = (item: allowedPrimitiveT | optionT) =>
typeof item === 'object' ? item.value : item
export const getLabel = (item: allowedPrimitiveT | optionT) =>
typeof item === 'object' ? item.label : `${item}`
export function getKey(item: allowedPrimitiveT | optionT, i: number): string {
const val = getValue(item)
if (typeof val === 'boolean') return `key-${val}`
if (val === '---') return `---${i}`
return `${val}`
}
export function extractText(node: ReactNode): string {
if (node == null || typeof node === 'boolean') return ''
if (typeof node === 'string' || typeof node === 'number') return String(node)
if (isValidElement(node)) return extractText((node.props as { children?: ReactNode }).children)
if (Array.isArray(node)) return node.map(extractText).join('')
return ''
}
'use client'
import * as React from 'react'
import { ChevronDownIcon, XIcon, Loader2 } from 'lucide-react'
import { Autocomplete as AutocompletePrimitive } from '@base-ui/react/autocomplete'
import { cn, extractText, getKey, getLabel, getValue, isGroup, isOption, isSeparator } from '@/lib/utils'
const AutocompleteRoot = AutocompletePrimitive.Root
function AutocompleteTrigger({ className, children, ...props }: AutocompletePrimitive.Trigger.Props) {
return (
<AutocompletePrimitive.Trigger
data-slot="autocomplete-trigger"
className={cn(
'flex size-7 shrink-0 items-center justify-center rounded-sm text-muted-foreground',
'transition-colors hover:bg-accent hover:text-foreground',
'[&_svg]:pointer-events-none',
className,
)}
{...props}
>
{children ?? <ChevronDownIcon className="size-4" />}
</AutocompletePrimitive.Trigger>
)
}
function AutocompleteClear({ className, ...props }: AutocompletePrimitive.Clear.Props) {
return (
<AutocompletePrimitive.Clear
data-slot="autocomplete-clear"
className={cn(
'flex size-7 shrink-0 items-center justify-center rounded-sm text-muted-foreground',
'transition-colors hover:bg-accent hover:text-foreground',
'[&_svg]:pointer-events-none',
className,
)}
{...props}
>
<XIcon className="size-3.5" />
</AutocompletePrimitive.Clear>
)
}
function AutocompleteInput({
className,
showClear = false,
showTrigger = false,
disabled,
...props
}: AutocompletePrimitive.Input.Props & {
showClear?: boolean
showTrigger?: boolean
disabled?: boolean
}) {
return (
<AutocompletePrimitive.InputGroup
data-slot="autocomplete-input-group"
className={cn(
'relative flex h-9 w-full items-center gap-0.5 rounded-md border border-input bg-transparent pl-3 pr-1 text-sm shadow-xs',
'transition-colors',
'focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50',
'has-aria-invalid:border-destructive has-aria-invalid:ring-3 has-aria-invalid:ring-destructive/20',
'dark:bg-input/30 dark:has-aria-invalid:border-destructive/50 dark:has-aria-invalid:ring-destructive/40',
className,
)}
>
<AutocompletePrimitive.Input
disabled={disabled}
className={cn(
'h-full min-w-0 flex-1 bg-transparent outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
)}
{...props}
/>
{(showClear || showTrigger) && (
<div className="ml-auto flex shrink-0 items-center">
{showClear && <AutocompleteClear disabled={disabled} />}
{showTrigger && <AutocompleteTrigger disabled={disabled} />}
</div>
)}
</AutocompletePrimitive.InputGroup>
)
}
function AutocompleteContent({
className,
side = 'bottom',
sideOffset = 6,
align = 'start',
alignOffset = 0,
...props
}: AutocompletePrimitive.Popup.Props &
Pick<AutocompletePrimitive.Positioner.Props, 'side' | 'align' | 'sideOffset' | 'alignOffset'>) {
return (
<AutocompletePrimitive.Portal>
<AutocompletePrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
>
<AutocompletePrimitive.Popup
data-slot="autocomplete-content"
className={cn(
'group/autocomplete-content relative overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md',
'max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) origin-(--transform-origin)',
'min-w-[max(var(--anchor-width),10rem)]',
'data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95',
'data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
'data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2',
'data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2',
className,
)}
{...props}
/>
</AutocompletePrimitive.Positioner>
</AutocompletePrimitive.Portal>
)
}
function AutocompleteList({ className, ...props }: AutocompletePrimitive.List.Props) {
return (
<AutocompletePrimitive.List
data-slot="autocomplete-list"
className={cn(
'max-h-(--available-height) overflow-y-auto overscroll-contain scroll-py-1 p-1',
className,
)}
{...props}
/>
)
}
function AutocompleteItem({
className,
children,
...props
}: AutocompletePrimitive.Item.Props) {
return (
<AutocompletePrimitive.Item
data-slot="autocomplete-item"
className={cn(
'relative flex w-full cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none',
'data-highlighted:bg-accent data-highlighted:text-accent-foreground',
'data-disabled:pointer-events-none data-disabled:opacity-50',
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
</AutocompletePrimitive.Item>
)
}
function AutocompleteGroup({ className, ...props }: AutocompletePrimitive.Group.Props) {
return (
<AutocompletePrimitive.Group
data-slot="autocomplete-group"
className={cn('pb-1', className)}
{...props}
/>
)
}
function AutocompleteLabel({ className, ...props }: AutocompletePrimitive.GroupLabel.Props) {
return (
<AutocompletePrimitive.GroupLabel
data-slot="autocomplete-label"
className={cn('px-2 py-1.5 text-xs font-medium text-muted-foreground', className)}
{...props}
/>
)
}
function AutocompleteCollection(props: AutocompletePrimitive.Collection.Props) {
return <AutocompletePrimitive.Collection data-slot="autocomplete-collection" {...props} />
}
function AutocompleteEmpty({ className, ...props }: AutocompletePrimitive.Empty.Props) {
return (
<AutocompletePrimitive.Empty
data-slot="autocomplete-empty"
className={cn('text-center text-sm text-muted-foreground', className)}
{...props}
/>
)
}
function AutocompleteSeparator({ className, ...props }: AutocompletePrimitive.Separator.Props) {
return (
<AutocompletePrimitive.Separator
data-slot="autocomplete-separator"
className={cn('-mx-1 my-1 h-px bg-border', className)}
{...props}
/>
)
}
function AutocompleteStatus({ className, ...props }: AutocompletePrimitive.Status.Props) {
return (
<AutocompletePrimitive.Status
data-slot="autocomplete-status"
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
)
}
type OptionItemProps = {
option: allowedPrimitiveT | optionT
className?: string
}
function OptionItem({ option, className }: OptionItemProps) {
const value = getValue(option)
const label = getLabel(option)
const optCls = isOption(option) ? option.className : undefined
const disabled = isOption(option) ? option.disabled : undefined
return (
<AutocompleteItem value={value} className={cn(className, optCls)} disabled={disabled}>
{label}
</AutocompleteItem>
)
}
type OptionsBodyProps = {
item: optionsT[number]
index: number
itemCls?: string
groupCls?: string
}
function OptionsBody({ item, index, itemCls, groupCls }: OptionsBodyProps) {
if (isGroup(item)) {
return (
<AutocompleteGroup
items={item.options as (allowedPrimitiveT | optionT)[]}
className={cn(groupCls, item.className)}
>
<AutocompleteLabel>{item.group}</AutocompleteLabel>
<AutocompleteCollection>
{(opt: allowedPrimitiveT | optionT, i) => (
<OptionItem
key={getKey(opt, i)}
option={opt}
className={itemCls}
/>
)}
</AutocompleteCollection>
</AutocompleteGroup>
)
}
if (isSeparator(item)) {
return <AutocompleteSeparator key={`sep-${index}`} />
}
return (
<OptionItem
key={getKey(item as allowedPrimitiveT | optionT, index)}
option={item as allowedPrimitiveT | optionT}
className={itemCls}
/>
)
}
type AutocompleteWrapperProps = Omit<
AutocompletePrimitive.Root.Props<unknown>,
'items' | 'itemToStringValue'
> & {
items?: optionsT
className?: string
placeholder?: string
isLoading?: boolean
emptyMessage?: string
showClear?: boolean
showTrigger?: boolean
contentCls?: string
itemCls?: string
groupCls?: string
inputProps?: Omit<React.ComponentProps<'input'>, 'value' | 'onChange'>
renderStatus?: React.ReactNode
renderEmpty?: React.ReactNode
}
function AutocompleteWrapper({
items,
className,
placeholder,
isLoading,
emptyMessage,
showClear = false,
showTrigger = true,
contentCls,
itemCls,
groupCls,
inputProps,
renderStatus,
renderEmpty,
disabled,
...props
}: AutocompleteWrapperProps) {
const itemsForBase = React.useMemo(() => {
if (!items) return []
return items.map(item => (isGroup(item) ? { ...item, items: item.options } : item))
}, [items])
return (
<AutocompleteRoot
items={itemsForBase as unknown[]}
disabled={disabled}
itemToStringValue={(item: unknown) => {
const opt = item as allowedPrimitiveT | optionT
const label = getLabel(opt)
if (typeof label === 'string') return label
return extractText(label).trim() || String(getValue(opt))
}}
{...props}
>
<AutocompleteInput
disabled={disabled}
showClear={showClear}
showTrigger={showTrigger}
placeholder={placeholder}
className={className}
{...(inputProps as AutocompletePrimitive.Input.Props)}
/>
<AutocompleteContent className={contentCls}>
<AutocompleteStatus>
{renderStatus !== undefined
? renderStatus
: isLoading && <p className='flex items-center justify-center gap-2 py-6'><Loader2 className='size-4 animate-spin' /> Loading...</p>}
</AutocompleteStatus>
<AutocompleteEmpty>
{renderEmpty !== undefined
? renderEmpty
: !isLoading && <p className='py-6'>{emptyMessage ?? 'No options found'}</p>}
</AutocompleteEmpty>
<AutocompleteList>
{(item: unknown, i: number) => (
<OptionsBody
key={
isGroup(item as optionsT[number])
? (item as groupT).group
: isSeparator(item as optionsT[number])
? `sep-${i}`
: String(getValue(item as allowedPrimitiveT | optionT))
}
item={item as optionsT[number]}
index={i}
itemCls={itemCls}
groupCls={groupCls}
/>
)}
</AutocompleteList>
</AutocompleteContent>
</AutocompleteRoot>
)
}
export {
AutocompleteRoot,
AutocompleteInput,
AutocompleteContent,
AutocompleteList,
AutocompleteItem,
AutocompleteGroup,
AutocompleteLabel,
AutocompleteCollection,
AutocompleteEmpty,
AutocompleteSeparator,
AutocompleteTrigger,
AutocompleteClear,
AutocompleteStatus,
AutocompleteWrapper,
type AutocompleteWrapperProps,
}
Virtualised Installation
AutocompleteVirtualisedWrapper renders large flat lists efficiently using @tanstack/react-virtual. Install separately — grouping is not supported.
npx shadcn@latest add @glrk-ui/autocomplete-virtual'use client'
import * as React from 'react'
import { Loader2 } from 'lucide-react'
import { Autocomplete as AutocompletePrimitive } from '@base-ui/react/autocomplete'
import { useVirtualizer, type VirtualizerOptions } from '@tanstack/react-virtual'
import { cn, extractText, getLabel, getValue, isGroup, isOption } from '@/lib/utils'
import {
AutocompleteRoot,
AutocompleteInput,
AutocompleteContent,
AutocompleteItem,
AutocompleteEmpty,
AutocompleteStatus,
} from '@/components/ui/autocomplete'
type AutocompleteVirtualListProps = {
itemCls?: string
estimateSize: number
maxHeight: number
virtualizerOptions?: Partial<Omit<VirtualizerOptions<HTMLDivElement, Element>, 'count' | 'getScrollElement'>>
}
function AutocompleteVirtualList({ itemCls, estimateSize, maxHeight, virtualizerOptions }: AutocompleteVirtualListProps) {
const items = (AutocompletePrimitive.useFilteredItems() ?? []) as (allowedPrimitiveT | optionT)[]
const parentRef = React.useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => estimateSize,
overscan: 5,
...virtualizerOptions,
})
return (
<AutocompletePrimitive.List data-slot="autocomplete-list">
<div ref={parentRef} className="overflow-y-auto overscroll-contain p-1" style={{ maxHeight }}>
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map(virtualRow => {
const item = items[virtualRow.index]
if (!item) return null
const value = getValue(item)
const label = getLabel(item)
const optCls = isOption(item) ? item.className : undefined
const disabled = isOption(item) ? item.disabled : undefined
return (
<AutocompleteItem
key={String(value)}
value={value}
ref={virtualizer.measureElement}
data-index={virtualRow.index}
disabled={disabled}
className={cn(itemCls, optCls)}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualRow.start}px)`,
}}
>
{label}
</AutocompleteItem>
)
})}
</div>
</div>
</AutocompletePrimitive.List>
)
}
type AutocompleteVirtualisedWrapperProps = Omit<
AutocompletePrimitive.Root.Props<unknown>,
'items' | 'itemToStringValue'
> & {
items?: optionsT
className?: string
placeholder?: string
isLoading?: boolean
emptyMessage?: string
showClear?: boolean
showTrigger?: boolean
contentCls?: string
itemCls?: string
inputProps?: Omit<React.ComponentProps<'input'>, 'value' | 'onChange'>
renderStatus?: React.ReactNode
renderEmpty?: React.ReactNode
estimateSize?: number
maxHeight?: number
virtualizerOptions?: Partial<Omit<VirtualizerOptions<HTMLDivElement, Element>, 'count' | 'getScrollElement'>>
}
function AutocompleteVirtualisedWrapper({
items,
className,
placeholder,
isLoading,
emptyMessage,
showClear = false,
showTrigger = true,
contentCls,
itemCls,
inputProps,
renderStatus,
renderEmpty,
estimateSize = 32,
maxHeight = 300,
virtualizerOptions,
disabled,
...props
}: AutocompleteVirtualisedWrapperProps) {
const itemsForBase = React.useMemo(() => {
if (!items) return []
return items.filter(item => !isGroup(item)) as (allowedPrimitiveT | optionT)[]
}, [items])
return (
<AutocompleteRoot
items={itemsForBase as unknown[]}
disabled={disabled}
virtualized
itemToStringValue={(item: unknown) => {
const opt = item as allowedPrimitiveT | optionT
const label = getLabel(opt)
if (typeof label === 'string') return label
return extractText(label).trim() || String(getValue(opt))
}}
{...props}
>
<AutocompleteInput
disabled={disabled}
showClear={showClear}
showTrigger={showTrigger}
placeholder={placeholder}
className={className}
{...(inputProps as AutocompletePrimitive.Input.Props)}
/>
<AutocompleteContent className={contentCls}>
<AutocompleteStatus>
{renderStatus !== undefined
? renderStatus
: isLoading && <p className='flex items-center justify-center gap-2 py-6'><Loader2 className='size-4 animate-spin' /> Loading...</p>}
</AutocompleteStatus>
<AutocompleteEmpty>
{renderEmpty !== undefined
? renderEmpty
: !isLoading && <p className='py-6'>{emptyMessage ?? 'No options found'}</p>}
</AutocompleteEmpty>
<AutocompleteVirtualList
itemCls={itemCls}
estimateSize={estimateSize}
maxHeight={maxHeight}
virtualizerOptions={virtualizerOptions}
/>
</AutocompleteContent>
</AutocompleteRoot>
)
}
export {
AutocompleteVirtualisedWrapper,
type AutocompleteVirtualisedWrapperProps,
}
Usage
Basic
import { AutocompleteWrapper } from "@/components/ui/autocomplete"
<AutocompleteWrapper
items={["Apple", "Banana", "Orange"]}
placeholder="Search fruits..."
className="w-64"
/>With clear and trigger buttons
<AutocompleteWrapper
items={items}
placeholder="Search..."
showClear
showTrigger
className="w-64"
/>Controlled
const [value, setValue] = useState("")
<AutocompleteWrapper
items={items}
value={value}
onValueChange={setValue}
placeholder="Search..."
showClear
/>Option objects
const frameworks = [
{ value: "next", label: "Next.js" },
{ value: "remix", label: "Remix" },
{ value: "astro", label: "Astro" },
]
<AutocompleteWrapper items={frameworks} placeholder="Select framework..." />Grouped options
const grouped = [
{
group: "Frontend",
options: [
{ value: "react", label: "React" },
{ value: "vue", label: "Vue" },
],
},
{
group: "Backend",
options: [
{ value: "node", label: "Node.js" },
{ value: "go", label: "Go" },
],
},
]
<AutocompleteWrapper items={grouped} placeholder="Search tech..." showClear />Limit results
<AutocompleteWrapper items={items} placeholder="Search..." limit={5} />Inline mode
Completes input text inline as the user types:
<AutocompleteWrapper items={items} mode="inline" placeholder="Start typing..." />Custom status and empty slots
Override the default loading/empty UI with renderStatus and renderEmpty:
<AutocompleteWrapper
items={items}
renderStatus={isFetching
? <p className="flex items-center gap-2 p-2 text-sm"><Loader2 className="size-4 animate-spin" /> Fetching...</p>
: null
}
renderEmpty={<p className="py-6 text-center text-sm">Nothing here yet</p>}
/>Async search
Disable client-side filter with filter={null} and supply results externally:
const [inputValue, setInputValue] = useState("")
const [results, setResults] = useState<string[]>([])
const [isFetching, setIsFetching] = useState(false)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
function handleValueChange(val: string) {
setInputValue(val)
if (timerRef.current) clearTimeout(timerRef.current)
if (!val.trim()) { setResults([]); return }
setIsFetching(true)
timerRef.current = setTimeout(async () => {
setResults(await fetchSuggestions(val))
setIsFetching(false)
}, 400)
}
<AutocompleteWrapper
items={results}
value={inputValue}
onValueChange={handleValueChange}
filter={null}
placeholder="Search..."
showClear
renderStatus={isFetching
? <p className="flex items-center gap-2 p-2 text-sm"><Loader2 className="size-4 animate-spin" /> Fetching...</p>
: null
}
renderEmpty={<p className="py-6 text-center text-sm">No results found</p>}
/>Virtualised — large lists
Use AutocompleteVirtualisedWrapper for lists with hundreds or thousands of items:
import { AutocompleteVirtualisedWrapper } from "@/components/ui/autocomplete-virtualised"
<AutocompleteVirtualisedWrapper
items={largeList}
placeholder="Search 1000 options…"
className="w-64"
showClear
/>Custom row height and overscan:
<AutocompleteVirtualisedWrapper
items={largeList}
placeholder="Search…"
maxHeight={200}
estimateSize={36}
virtualizerOptions={{ overscan: 10 }}
/>Reference
Prop
Type
Virtualised Reference
AutocompleteVirtualisedWrapper accepts all the same props as AutocompleteWrapper except groupCls. The following props are unique to the virtualised variant:
Prop
Type
Related Components
Combobox
A wrapper for Base UI Combobox with single and multi-select, chips, grouped options, async loading, custom label rendering, and create-new support.
Number Field
A wrapper for Base UI NumberField with increment/decrement buttons, min/max constraints, step control, Intl number formatting, and wheel/keyboard scrubbing.