Async comboboxes
Five comboboxes built around the states a real search hits: loading, empty, error, recent and suggest. Every state is reachable by typing, the delay is a fixed constant cleared on unmount, and each is self-contained (no radix, no cmdk), sized, token-driven, with arrow keys plus Enter.
Installation
The styled layer is free and installs like any other ai2 component.
Run the following command
npx shadcn@latest add @ai2/combobox-asyncDependencies, the @ai2/tokens theme and the component file are installed together.
Install dependencies
npm install motion lucide-reactCopy the source
components/ui/combobox-async.tsx"use client"
import * as React from "react"
import {
AlertTriangle,
Check,
ChevronsUpDown,
Clock,
Lightbulb,
Loader2,
RotateCw,
Search,
SearchX,
} from "lucide-react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { cn } from "@/lib/utils"
/* Async combobox family: 5 decorative SELF-CONTAINED searchable selects. NO
radix, NO portal, NO cmdk. This family shows the UNHAPPY states: loading, empty
result, error, recent searches, suggestion. Every state is reachable BY TYPING.
The timing uses a FIXED timeout constant (NO Date.now/Math.random) and is
cleared in the effect cleanup. Each export is a complete combobox: a real
<input> carrying role="combobox" filters, the arrow keys move the highlight,
Enter selects, Escape closes. Color comes ONLY from tokens. */
export type StyledSize = "sm" | "md" | "lg" | "xl"
/* Sahte agdaki gecikme: sabit, deterministik. */
const LOADING_MS = 600
const triggerHeight: Record<StyledSize, string> = {
sm: "h-8",
md: "h-9",
lg: "h-10",
xl: "h-12",
}
const panelWidth: Record<StyledSize, string> = {
sm: "w-56",
md: "w-64",
lg: "w-72",
xl: "w-80",
}
export interface ComboboxOption {
value: string
label: React.ReactNode
}
interface ComboboxProps {
className?: string
size?: StyledSize
placeholder?: string
options?: ComboboxOption[]
value?: string
defaultValue?: string
onValueChange?: (v: string) => void
}
const defaultOptions: ComboboxOption[] = [
{ value: "next", label: "Next.js" },
{ value: "react", label: "React" },
{ value: "vue", label: "Vue" },
{ value: "svelte", label: "Svelte" },
{ value: "solid", label: "Solid" },
{ value: "astro", label: "Astro" },
]
function nodeText(node: React.ReactNode): string {
if (node === null || node === undefined || typeof node === "boolean") return ""
if (typeof node === "string" || typeof node === "number") return String(node)
if (Array.isArray(node)) return node.map(nodeText).join("")
if (React.isValidElement(node)) {
return nodeText((node.props as { children?: React.ReactNode }).children)
}
return ""
}
function filterOptions(options: ComboboxOption[], query: string): ComboboxOption[] {
const q = query.trim().toLowerCase()
if (q.length === 0) return options
return options.filter((o) => nodeText(o.label).toLowerCase().includes(q))
}
const rootBase = "relative"
const inputBase =
"w-full rounded-lg border border-field-border bg-transparent pl-9 pr-9 text-sm text-foreground shadow-xs outline-none transition-colors placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-danger aria-invalid:ring-danger/20 dark:aria-invalid:ring-danger/40"
const panelBase =
"absolute left-0 right-0 top-full z-50 mt-2 min-w-56 origin-top overflow-hidden rounded-xl border border-border bg-popover text-popover-foreground shadow-lg outline-none"
const listBase = "max-h-60 overflow-y-auto p-1.5"
const optionBase =
"flex cursor-pointer select-none items-center gap-2 rounded-lg px-2.5 py-2 text-sm outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50 [&_svg]:size-4 [&_svg]:shrink-0 [&_i]:text-base [&_i]:leading-none"
const stateBase = "flex flex-col items-center gap-2 px-4 py-7 text-center"
const sectionLabel =
"px-2.5 pb-1 pt-2 text-[11px] font-medium uppercase tracking-wide text-muted-foreground"
const panelMotion = {
initial: { opacity: 0, scale: 0.98, y: -6 },
animate: { opacity: 1, scale: 1, y: 0 },
exit: { opacity: 0, scale: 0.98, y: -6 },
}
function useCombobox(props: ComboboxProps) {
const { options = defaultOptions, value, defaultValue, onValueChange } = props
const isControlled = value !== undefined
const [internal, setInternal] = React.useState<string | undefined>(defaultValue)
const current = isControlled ? value : internal
const [open, setOpen] = React.useState(false)
const [editing, setEditing] = React.useState(false)
const [query, setQuery] = React.useState("")
const [active, setActive] = React.useState(0)
const wrapperRef = React.useRef<HTMLDivElement>(null)
const uid = React.useId()
const listId = `${uid}-list`
const optionId = React.useCallback((i: number) => `${uid}-option-${i}`, [uid])
const selected = React.useMemo(
() => (current === undefined ? undefined : options.find((o) => o.value === current)),
[options, current]
)
const filtered = React.useMemo(
() => (editing ? filterOptions(options, query) : options),
[editing, options, query]
)
React.useEffect(() => {
setActive((prev) => (prev >= filtered.length ? 0 : prev))
}, [filtered.length])
const close = React.useCallback(() => {
setOpen(false)
setEditing(false)
setQuery("")
setActive(0)
}, [])
React.useEffect(() => {
if (!open) return
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") close()
}
const onPointer = (e: PointerEvent) => {
const node = wrapperRef.current
if (node && e.target instanceof Node && !node.contains(e.target)) close()
}
window.addEventListener("keydown", onKey)
window.addEventListener("pointerdown", onPointer)
return () => {
window.removeEventListener("keydown", onKey)
window.removeEventListener("pointerdown", onPointer)
}
}, [open, close])
const commit = React.useCallback(
(next: string) => {
if (!isControlled) setInternal(next)
onValueChange?.(next)
close()
},
[isControlled, onValueChange, close]
)
const onInputKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "ArrowDown") {
e.preventDefault()
setOpen(true)
setActive((prev) => (filtered.length === 0 ? 0 : (prev + 1) % filtered.length))
} else if (e.key === "ArrowUp") {
e.preventDefault()
setOpen(true)
setActive((prev) =>
filtered.length === 0 ? 0 : (prev - 1 + filtered.length) % filtered.length
)
} else if (e.key === "Enter") {
e.preventDefault()
const item = filtered[active]
if (item) commit(item.value)
} else if (e.key === "Escape") {
e.preventDefault()
close()
}
},
[filtered, active, commit, close]
)
const inputValue = editing ? query : selected ? nodeText(selected.label) : ""
return {
open,
setOpen,
editing,
setEditing,
query,
setQuery,
active,
setActive,
filtered,
current,
commit,
close,
onInputKeyDown,
wrapperRef,
inputValue,
listId,
optionId,
}
}
type ComboboxState = ReturnType<typeof useCombobox>
/* Presentation shell: input plus panel. The panel body arrives through children, so every variant draws its own loading, empty and error state. */
function ComboboxField({
state,
size,
className,
placeholder,
trailing,
children,
}: {
state: ComboboxState
size: StyledSize
className?: string
placeholder: string
trailing?: React.ReactNode
children: React.ReactNode
}) {
const reduce = useReducedMotion() ?? false
const {
open,
setOpen,
setEditing,
setQuery,
active,
filtered,
onInputKeyDown,
wrapperRef,
inputValue,
listId,
optionId,
} = state
const activeId = open && filtered[active] ? optionId(active) : undefined
return (
<div
ref={wrapperRef}
data-slot="styled-combobox"
className={cn(rootBase, panelWidth[size], className)}
>
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<input
role="combobox"
aria-expanded={open}
aria-controls={listId}
aria-autocomplete="list"
aria-activedescendant={activeId}
value={inputValue}
placeholder={placeholder}
onFocus={() => {
setOpen(true)
setEditing(false)
}}
onChange={(e) => {
setOpen(true)
setEditing(true)
setQuery(e.target.value)
}}
onKeyDown={onInputKeyDown}
className={cn(inputBase, triggerHeight[size])}
/>
<span className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground [&_svg]:size-4 [&_i]:text-base [&_i]:leading-none">
{trailing ?? <ChevronsUpDown className="opacity-70" />}
</span>
</div>
<AnimatePresence>
{open ? (
<motion.div
id={listId}
role="listbox"
className={panelBase}
initial={reduce ? { opacity: 0 } : panelMotion.initial}
animate={reduce ? { opacity: 1 } : panelMotion.animate}
exit={reduce ? { opacity: 0 } : panelMotion.exit}
transition={reduce ? { duration: 0 } : { duration: 0.16, ease: "easeOut" }}
>
{children}
</motion.div>
) : null}
</AnimatePresence>
</div>
)
}
/* Ortak secenek satiri cizimi. */
function OptionRows({ state }: { state: ComboboxState }) {
const { filtered, active, setActive, current, commit, optionId } = state
return (
<>
{filtered.map((option, i) => {
const isActive = i === active
const isSelected = option.value === current
return (
<div
key={option.value}
id={optionId(i)}
role="option"
tabIndex={-1}
aria-selected={isSelected}
onMouseEnter={() => setActive(i)}
onClick={() => commit(option.value)}
className={cn(optionBase, (isActive || isSelected) && "bg-accent text-accent-foreground")}
>
<span className="flex-1 truncate">{option.label}</span>
{isSelected ? <Check className="text-foreground" /> : null}
</div>
)
})}
</>
)
}
/* LoadingCombobox: on every keystroke it shows a skeleton for a FIXED LOADING_MS,
then opens the results. The timeout is cleared in the effect cleanup. */
export function LoadingCombobox(props: ComboboxProps) {
const { className, size = "md", placeholder = "Search framework..." } = props
const state = useCombobox(props)
const reduce = useReducedMotion() ?? false
const { query, editing, filtered } = state
const [loading, setLoading] = React.useState(false)
React.useEffect(() => {
if (!editing) return
setLoading(true)
const id = window.setTimeout(() => setLoading(false), LOADING_MS)
return () => {
window.clearTimeout(id)
}
}, [query, editing])
return (
<ComboboxField
state={state}
size={size}
className={className}
placeholder={placeholder}
trailing={
loading ? (
<Loader2 className={cn("text-muted-foreground", !reduce && "animate-spin")} />
) : undefined
}
>
<div className={listBase}>
{loading ? (
<div className="space-y-1.5 p-1" aria-live="polite">
{[0, 1, 2].map((i) => (
<div key={i} className="flex items-center gap-2 rounded-lg px-1.5 py-2">
<span
className={cn(
"h-3 flex-1 rounded-md bg-[color-mix(in_oklab,var(--color-foreground)_10%,transparent)]",
!reduce && "animate-pulse motion-reduce:animate-none"
)}
/>
</div>
))}
<p className="pt-1 text-center text-xs text-muted-foreground">Loading results...</p>
</div>
) : filtered.length === 0 ? (
<p className={cn(stateBase, "text-sm text-muted-foreground")}>No results found.</p>
) : (
<OptionRows state={state} />
)}
</div>
</ComboboxField>
)
}
/* EmptyCombobox: with no match it shows an empty-state card with an icon and a
description. */
export function EmptyCombobox(props: ComboboxProps) {
const { className, size = "md", placeholder = "Search framework..." } = props
const state = useCombobox(props)
const { filtered, query } = state
return (
<ComboboxField state={state} size={size} className={className} placeholder={placeholder}>
<div className={listBase}>
{filtered.length === 0 ? (
<div className={stateBase}>
<span className="flex size-9 items-center justify-center rounded-full bg-[color-mix(in_oklab,var(--color-foreground)_8%,transparent)] text-muted-foreground [&_svg]:size-4 [&_i]:text-base [&_i]:leading-none">
<SearchX />
</span>
<p className="text-sm font-medium text-foreground">No matches</p>
<p className="text-xs text-muted-foreground">
Nothing matches {query.trim().length > 0 ? `"${query.trim()}"` : "your search"}. Try a
shorter term.
</p>
</div>
) : (
<OptionRows state={state} />
)}
</div>
</ComboboxField>
)
}
/* ErrorCombobox: an error state plus Retry when there is no match. Retry clears the query and the list comes back. It uses the danger token (NOT destructive). */
export function ErrorCombobox(props: ComboboxProps) {
const { className, size = "md", placeholder = "Search framework..." } = props
const state = useCombobox(props)
const { filtered, setQuery, setEditing, setActive } = state
const retry = React.useCallback(() => {
setQuery("")
setEditing(false)
setActive(0)
}, [setQuery, setEditing, setActive])
return (
<ComboboxField state={state} size={size} className={className} placeholder={placeholder}>
<div className={listBase}>
{filtered.length === 0 ? (
<div className={stateBase}>
<span className="flex size-9 items-center justify-center rounded-full bg-[color-mix(in_oklab,var(--color-danger)_14%,transparent)] text-danger [&_svg]:size-4 [&_i]:text-base [&_i]:leading-none">
<AlertTriangle />
</span>
<p className="text-sm font-medium text-foreground">Search failed</p>
<p className="text-xs text-muted-foreground">
We could not reach the index for that term.
</p>
<button
type="button"
onClick={retry}
className="mt-1 inline-flex h-8 items-center gap-1.5 rounded-lg border border-border bg-transparent px-3 text-xs font-medium text-foreground outline-none transition-colors hover:bg-[color-mix(in_oklab,var(--color-foreground)_6%,transparent)] focus-visible:ring-[3px] focus-visible:ring-ring/50 [&_svg]:size-3.5 [&_svg]:shrink-0 [&_i]:text-sm [&_i]:leading-none"
>
<RotateCw />
Retry
</button>
</div>
) : (
<OptionRows state={state} />
)}
</div>
</ComboboxField>
)
}
/* RecentCombobox: while the query is empty it shows the most recent selections under
a "Recent" heading; typing switches to the normal filtered list. */
export function RecentCombobox(props: ComboboxProps) {
const { className, size = "md", placeholder = "Search framework..." } = props
const state = useCombobox(props)
const { filtered, editing, query } = state
const showRecent = !editing || query.trim().length === 0
return (
<ComboboxField state={state} size={size} className={className} placeholder={placeholder}>
<div className={listBase}>
{showRecent ? (
<p className={sectionLabel}>
<span className="inline-flex items-center gap-1.5">
<Clock className="size-3" />
Recent
</span>
</p>
) : null}
{filtered.length === 0 ? (
<p className={cn(stateBase, "text-sm text-muted-foreground")}>No results found.</p>
) : (
<OptionRows state={state} />
)}
</div>
</ComboboxField>
)
}
/* SuggestCombobox: eslesme yoksa DETERMINISTIK bir oneri sunar (ilk harfi
paylasan ilk secenek, yoksa listenin ilki). Tiklayinca onu secer. */
export function SuggestCombobox(props: ComboboxProps) {
const { className, size = "md", placeholder = "Search framework...", options = defaultOptions } = props
const state = useCombobox(props)
const { filtered, query, commit } = state
const suggestion = React.useMemo(() => {
const q = query.trim().toLowerCase()
if (q.length === 0) return options[0]
const byFirstChar = options.find((o) => nodeText(o.label).toLowerCase().startsWith(q[0]))
return byFirstChar ?? options[0]
}, [options, query])
return (
<ComboboxField state={state} size={size} className={className} placeholder={placeholder}>
<div className={listBase}>
{filtered.length === 0 ? (
<div className={stateBase}>
<span className="flex size-9 items-center justify-center rounded-full bg-[color-mix(in_oklab,var(--color-info)_14%,transparent)] text-info [&_svg]:size-4 [&_i]:text-base [&_i]:leading-none">
<Lightbulb />
</span>
<p className="text-sm font-medium text-foreground">No exact match</p>
{suggestion ? (
<button
type="button"
onClick={() => commit(suggestion.value)}
className="mt-1 inline-flex h-8 items-center gap-1.5 rounded-lg border border-border bg-transparent px-3 text-xs font-medium text-foreground outline-none transition-colors hover:bg-[color-mix(in_oklab,var(--color-foreground)_6%,transparent)] focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
Did you mean {nodeText(suggestion.label)}?
</button>
) : null}
</div>
) : (
<OptionRows state={state} />
)}
</div>
</ComboboxField>
)
}Manual installs skip the @ai2/tokens theme, so add the token CSS from the theming guide or the tone colors will be missing.
Variations
5 takes on the same idea. Each is its own export, and every one accepts a size prop (sm, md, lg, xl) aligned to the base Button scale.
Loading
Typing shows a skeleton list for a fixed delay.
import { LoadingCombobox } from "@/components/ui/combobox-async"
<LoadingCombobox />Empty
An illustrated empty state when nothing matches.
import { EmptyCombobox } from "@/components/ui/combobox-async"
<EmptyCombobox />Error
A danger-toned failure state with a retry action.
import { ErrorCombobox } from "@/components/ui/combobox-async"
<ErrorCombobox />Recent
Recent picks lead until you start typing.
import { RecentCombobox } from "@/components/ui/combobox-async"
<RecentCombobox />Suggest
Offers a did-you-mean option when there is no match.
import { SuggestCombobox } from "@/components/ui/combobox-async"
<SuggestCombobox />ai2 Async comboboxes: 5 styled variations on the token system
The ai2 Async comboboxes are a set of 5 decorative button variations from the styled layer of the @ai2 design system, built around searchable selects that show loading, empty, error, recent and suggestion states. They are free and MIT licensed, and every color comes from a semantic token, so they theme with the rest of ai2 in light and dark.
Motion runs on framer-motion: framer-motion fades and scales the option panel in from the trigger. The styled layer is opt-in, so the dependency only lands if you use it; the base components stay lean. Under reduced motion, the scale is skipped, the panel appears instantly and the skeletons stop pulsing.
What is in the ai2 Async comboboxes?
5 exports in one file: Loading, Empty, Error, Recent and Suggest. Each renders a native button and takes a size prop (sm, md, lg, xl) aligned to the base Button. They are separate from the base Button on purpose: the base keeps its clean variant, tone and size axes, while the styled layer carries the effects.
You own the file. Copy the one category file and you have all 5 variations, with no runtime dependency on ai2 itself.
Why use it
- On-system by construction: Every color resolves to an ai2 semantic token, so the buttons follow your theme in light and dark with no extra work.
- Effect without the sprawl: The decorations live in a dedicated styled file, so the base Button keeps its clean, predictable API.
- Accessible and honest: Each renders a real button element, keeps a visible focus ring, and respects prefers-reduced-motion.
Features
- Token-driven color: No hardcoded hex or oklch; the look recolors with your theme tokens.
- framer-motion: framer-motion fades and scales the option panel in from the trigger.
- Reduced-motion aware: Under prefers-reduced-motion, the scale is skipped, the panel appears instantly and the skeletons stop pulsing.
- Size aligned to the base: Every variation takes sm, md, lg and xl matching the base Button height scale, so styled and base buttons line up in a row.
Production tips
- Use it for emphasis, not everywhere: Styled buttons draw the eye. Reserve them for the one action you want people to take on a screen, and use the base Button for the rest.
- Keep labels as verbs: The decoration adds weight, so a clear action label keeps the button scannable.
- Pick one variation per surface: The variations share a family; using two different ones in the same view competes for attention.
Works with the rest of ai2
The Async comboboxes sit alongside the base Button and the rest of the @ai2 registry. They share the same token file, so a styled action next to a base button or a badge stays visually consistent in both modes.