Styled combobox
Five comboboxes: simple, glass, multi-select tags, icon and inline. Each is self-contained (no radix), sized, token-driven, filters as you type and supports 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-styledDependencies, the @ai2/tokens theme and the component file are installed together.
Install dependencies
npm install motion lucide-reactCopy the source
components/ui/combobox-styled.tsx"use client"
import * as React from "react"
import { Check, ChevronsUpDown, Hash, Search, X } from "lucide-react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { cn } from "@/lib/utils"
/* Combobox family: 5 decorative SELF-CONTAINED searchable selects. NO radix, NO
portal, NO cmdk. Each export is a complete combobox: a trigger showing the
current value; clicking it opens a panel absolutely positioned BELOW it (a
search <input> + a filtered list). Filtering is a case-insensitive `includes`
on the label. The arrow keys (Up/Down) move the highlighted index, Enter
selects, Escape or an outside click closes. Controlled (value/onValueChange) +
uncontrolled (defaultValue). AnimatePresence fade+scale; no animation under
reduced motion. Color comes ONLY from tokens, via alpha color-mix. */
export type StyledSize = "sm" | "md" | "lg" | "xl"
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
}
/* Varsayilan 6 secenek: prop'suz render eder. */
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" },
]
/* The label can be a ReactNode; flatten it to plain text for filtering. */
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 ""
}
/* Sorguyu filtreye ceviren ortak yardimci. */
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 triggerBase =
"flex w-full items-center justify-between gap-2 rounded-lg border border-field-border bg-transparent px-3 text-sm text-foreground shadow-xs outline-none transition-colors hover:bg-[color-mix(in_oklab,var(--color-foreground)_4%,transparent)] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 [&_svg]:size-4 [&_svg]:shrink-0 [&_svg]:text-muted-foreground [&_i]:text-base [&_i]:leading-none [&_i]:text-muted-foreground"
const panelBase =
"absolute left-0 right-0 top-full z-50 mt-2 origin-top overflow-hidden rounded-xl border border-border bg-popover text-popover-foreground shadow-lg outline-none"
const inputRowBase = "flex items-center gap-2 border-b border-border px-3"
const inputBase =
"h-10 w-full bg-transparent py-2.5 text-sm text-popover-foreground outline-none placeholder:text-muted-foreground"
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 [&_svg]:size-4 [&_svg]:shrink-0 [&_i]:text-base [&_i]:leading-none"
const emptyBase = "py-6 text-center text-sm 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 },
}
/* Shared state: controlled/uncontrolled value plus open state plus query plus highlighted index plus keyboard handling plus closing on outside click and Escape. */
function useSingleCombobox(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 [query, setQuery] = React.useState("")
const [active, setActive] = React.useState(0)
const wrapperRef = React.useRef<HTMLDivElement>(null)
const inputRef = React.useRef<HTMLInputElement>(null)
const filtered = React.useMemo(() => filterOptions(options, query), [options, query])
React.useEffect(() => {
setActive((prev) => (prev >= filtered.length ? 0 : prev))
}, [filtered.length])
const close = React.useCallback(() => {
setOpen(false)
setQuery("")
setActive(0)
}, [])
React.useEffect(() => {
if (!open) return
inputRef.current?.focus()
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()
setActive((prev) => (filtered.length === 0 ? 0 : (prev + 1) % filtered.length))
} else if (e.key === "ArrowUp") {
e.preventDefault()
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 selected = React.useMemo(
() => (current === undefined ? undefined : options.find((o) => o.value === current)),
[options, current]
)
return {
options,
current,
selected,
open,
setOpen,
query,
setQuery,
active,
setActive,
filtered,
commit,
close,
onInputKeyDown,
wrapperRef,
inputRef,
}
}
/* Panel body: the search row plus the filtered list plus the empty state. Through renderOption every variant draws the option row its own way. */
function ComboboxPanel({
state,
size,
placeholder,
panelClassName,
inputRowClassName,
activeClassName,
renderOption,
reduce,
}: {
state: ReturnType<typeof useSingleCombobox>
size: StyledSize
placeholder: string
panelClassName?: string
inputRowClassName?: string
activeClassName: string
renderOption: (option: ComboboxOption, isActive: boolean, isSelected: boolean) => React.ReactNode
reduce: boolean
}) {
const { open, query, setQuery, active, setActive, filtered, commit, current, onInputKeyDown, inputRef } =
state
return (
<AnimatePresence>
{open ? (
<motion.div
role="listbox"
className={cn(panelBase, panelWidth[size], panelClassName)}
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" }}
>
<div className={cn(inputRowBase, inputRowClassName)}>
<Search className="size-4 shrink-0 text-muted-foreground" />
<input
ref={inputRef}
aria-autocomplete="list"
value={query}
placeholder={placeholder}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onInputKeyDown}
className={inputBase}
/>
</div>
<div className={listBase}>
{filtered.length === 0 ? (
<p className={emptyBase}>No results found.</p>
) : (
filtered.map((option, i) => {
const isActive = i === active
const isSelected = option.value === current
return (
<div
key={option.value}
role="option"
aria-selected={isSelected}
onMouseEnter={() => setActive(i)}
onClick={() => commit(option.value)}
className={cn(optionBase, (isActive || isSelected) && activeClassName)}
>
{renderOption(option, isActive, isSelected)}
</div>
)
})
)}
</div>
</motion.div>
) : null}
</AnimatePresence>
)
}
/* SimpleCombobox: a clean bordered trigger + a plain list, with a check on the
selected row. */
export function SimpleCombobox({
className,
size = "md",
placeholder = "Search...",
options,
value,
defaultValue,
onValueChange,
}: ComboboxProps) {
const state = useSingleCombobox({ options, value, defaultValue, onValueChange })
const reduce = useReducedMotion() ?? false
const { open, setOpen, selected } = state
return (
<div ref={state.wrapperRef} data-slot="styled-combobox" className={cn(rootBase, panelWidth[size], className)}>
<button
type="button"
role="combobox"
aria-expanded={open}
aria-haspopup="listbox"
onClick={() => setOpen(!open)}
className={cn(triggerBase, triggerHeight[size])}
>
<span className={cn("truncate", !selected && "text-muted-foreground")}>
{selected ? selected.label : placeholder}
</span>
<ChevronsUpDown className="opacity-70" />
</button>
<ComboboxPanel
state={state}
size={size}
placeholder={placeholder}
activeClassName="bg-accent text-accent-foreground"
reduce={reduce}
renderOption={(option, _isActive, isSelected) => (
<>
<span className="flex-1 truncate">{option.label}</span>
{isSelected ? <Check className="text-foreground" /> : null}
</>
)}
/>
</div>
)
}
/* GlassCombobox: buzlu cam trigger + panel, backdrop-blur. */
export function GlassCombobox({
className,
size = "md",
placeholder = "Search...",
options,
value,
defaultValue,
onValueChange,
}: ComboboxProps) {
const state = useSingleCombobox({ options, value, defaultValue, onValueChange })
const reduce = useReducedMotion() ?? false
const { open, setOpen, selected } = state
const glassSurface =
"bg-[color-mix(in_oklab,var(--color-popover)_75%,transparent)] supports-[backdrop-filter]:bg-[color-mix(in_oklab,var(--color-popover)_55%,transparent)] supports-[backdrop-filter]:backdrop-blur-xl"
return (
<div ref={state.wrapperRef} data-slot="styled-combobox" className={cn(rootBase, panelWidth[size], className)}>
<button
type="button"
role="combobox"
aria-expanded={open}
aria-haspopup="listbox"
onClick={() => setOpen(!open)}
className={cn(
triggerBase,
triggerHeight[size],
"border-border",
glassSurface,
"shadow-[inset_0_1px_0_color-mix(in_oklab,var(--color-foreground)_12%,transparent)]"
)}
>
<span className={cn("truncate", !selected && "text-muted-foreground")}>
{selected ? selected.label : placeholder}
</span>
<ChevronsUpDown className="opacity-70" />
</button>
<ComboboxPanel
state={state}
size={size}
placeholder={placeholder}
panelClassName={cn(
"border-border",
glassSurface,
"shadow-[inset_0_1px_0_color-mix(in_oklab,var(--color-foreground)_12%,transparent)]"
)}
activeClassName="bg-[color-mix(in_oklab,var(--color-accent)_70%,transparent)] text-accent-foreground"
reduce={reduce}
renderOption={(option, _isActive, isSelected) => (
<>
<span className="flex-1 truncate">{option.label}</span>
{isSelected ? <Check className="text-foreground" /> : null}
</>
)}
/>
</div>
)
}
/* IconCombobox: every option gets a leading token icon + a right-aligned check when
selected. */
export function IconCombobox({
className,
size = "md",
placeholder = "Search...",
options,
value,
defaultValue,
onValueChange,
}: ComboboxProps) {
const state = useSingleCombobox({ options, value, defaultValue, onValueChange })
const reduce = useReducedMotion() ?? false
const { open, setOpen, selected } = state
return (
<div ref={state.wrapperRef} data-slot="styled-combobox" className={cn(rootBase, panelWidth[size], className)}>
<button
type="button"
role="combobox"
aria-expanded={open}
aria-haspopup="listbox"
onClick={() => setOpen(!open)}
className={cn(triggerBase, triggerHeight[size])}
>
<span className="flex min-w-0 items-center gap-2">
<Hash className="shrink-0" />
<span className={cn("truncate", !selected && "text-muted-foreground")}>
{selected ? selected.label : placeholder}
</span>
</span>
<ChevronsUpDown className="opacity-70" />
</button>
<ComboboxPanel
state={state}
size={size}
placeholder={placeholder}
activeClassName="bg-accent text-accent-foreground"
reduce={reduce}
renderOption={(option, _isActive, isSelected) => (
<>
<Hash className="text-muted-foreground" />
<span className="flex-1 truncate">{option.label}</span>
{isSelected ? <Check className="text-foreground" /> : null}
</>
)}
/>
</div>
)
}
/* TagsCombobox: MULTIPLE selection. The selected items sit inside the trigger as removable token tag chips; clicking an option toggles it and the panel stays open. */
export function TagsCombobox({
className,
size = "md",
placeholder = "Search...",
options = defaultOptions,
value,
defaultValue,
onValueChange,
}: ComboboxProps) {
const reduce = useReducedMotion() ?? false
const [selected, setSelected] = React.useState<string[]>(defaultValue ? [defaultValue] : [])
const [open, setOpen] = React.useState(false)
const [query, setQuery] = React.useState("")
const [active, setActive] = React.useState(0)
const wrapperRef = React.useRef<HTMLDivElement>(null)
const inputRef = React.useRef<HTMLInputElement>(null)
const filtered = React.useMemo(() => filterOptions(options, query), [options, query])
React.useEffect(() => {
setActive((prev) => (prev >= filtered.length ? 0 : prev))
}, [filtered.length])
React.useEffect(() => {
if (!open) return
inputRef.current?.focus()
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false)
}
const onPointer = (e: PointerEvent) => {
const node = wrapperRef.current
if (node && e.target instanceof Node && !node.contains(e.target)) setOpen(false)
}
window.addEventListener("keydown", onKey)
window.addEventListener("pointerdown", onPointer)
return () => {
window.removeEventListener("keydown", onKey)
window.removeEventListener("pointerdown", onPointer)
}
}, [open])
const toggle = React.useCallback(
(v: string) => {
setSelected((prev) => (prev.includes(v) ? prev.filter((x) => x !== v) : [...prev, v]))
onValueChange?.(v)
},
[onValueChange]
)
const onInputKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "ArrowDown") {
e.preventDefault()
setActive((prev) => (filtered.length === 0 ? 0 : (prev + 1) % filtered.length))
} else if (e.key === "ArrowUp") {
e.preventDefault()
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) toggle(item.value)
} else if (e.key === "Escape") {
e.preventDefault()
setOpen(false)
}
},
[filtered, active, toggle]
)
const selectedOptions = selected
.map((v) => options.find((o) => o.value === v))
.filter((o): o is ComboboxOption => o !== undefined)
return (
<div ref={wrapperRef} data-slot="styled-combobox" className={cn(rootBase, panelWidth[size], className)}>
{/* STRUCTURAL FIX (2026-07-28, the a11y triage wave).
This container used to be a <button role="combobox"> with the "remove"
control of the selection chips living INSIDE IT. Because a button
inside a button is invalid HTML, the remove control was written as a
`span role="button"` - and since it carried no tabIndex it was
COMPLETELY UNREACHABLE BY KEYBOARD: a selection could be removed with
the mouse but not with the keyboard.
The fix is the pattern the single-select variants in this same file
already use: the combobox role on a focusable CONTAINER, with the
remove controls as REAL <button> siblings. That leaves no nested
buttons and lets every control be focused on its own.
The keyboard contract is preserved: Enter/Space/ArrowDown opens the
list (the equivalent of the button behavior), and Backspace/Delete on
a chip removes the selection. */}
<div
role="combobox"
tabIndex={0}
aria-expanded={open}
aria-haspopup="listbox"
onClick={() => setOpen(!open)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " " || event.key === "ArrowDown") {
event.preventDefault()
setOpen(true)
} else if (event.key === "Escape") {
setOpen(false)
}
}}
className={cn(
triggerBase,
"h-auto min-h-9 cursor-pointer flex-wrap py-1.5",
size === "sm" && "min-h-8",
size === "lg" && "min-h-10",
size === "xl" && "min-h-12"
)}
>
<span className="flex flex-1 flex-wrap items-center gap-1.5">
{selectedOptions.length === 0 ? (
<span className="text-muted-foreground">{placeholder}</span>
) : (
selectedOptions.map((option) => (
<span
key={option.value}
className="inline-flex items-center gap-1 rounded-md border border-border bg-secondary px-1.5 py-0.5 text-xs font-medium text-secondary-foreground"
>
{option.label}
<button
type="button"
aria-label={`Remove ${option.label}`}
onClick={(e) => {
e.stopPropagation()
toggle(option.value)
}}
onKeyDown={(e) => e.stopPropagation()}
className="inline-flex rounded-sm text-muted-foreground transition-colors hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none [&_svg]:size-3"
>
<X />
</button>
</span>
))
)}
</span>
<ChevronsUpDown className="opacity-70" />
</div>
<AnimatePresence>
{open ? (
<motion.div
role="listbox"
aria-multiselectable="true"
className={cn(panelBase, panelWidth[size])}
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" }}
>
<div className={inputRowBase}>
<Search className="size-4 shrink-0 text-muted-foreground" />
<input
ref={inputRef}
aria-autocomplete="list"
value={query}
placeholder={placeholder}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onInputKeyDown}
className={inputBase}
/>
</div>
<div className={listBase}>
{filtered.length === 0 ? (
<p className={emptyBase}>No results found.</p>
) : (
filtered.map((option, i) => {
const isActive = i === active
const isSelected = selected.includes(option.value)
return (
<div
key={option.value}
role="option"
aria-selected={isSelected}
onMouseEnter={() => setActive(i)}
onClick={() => toggle(option.value)}
className={cn(
optionBase,
(isActive || isSelected) && "bg-accent text-accent-foreground"
)}
>
<span
className={cn(
"flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-border",
isSelected && "border-transparent bg-primary text-primary-foreground"
)}
>
{isSelected ? <Check className="size-3" /> : null}
</span>
<span className="flex-1 truncate">{option.label}</span>
</div>
)
})
)}
</div>
</motion.div>
) : null}
</AnimatePresence>
</div>
)
}
/* InlineCombobox: the search <input> ITSELF is the trigger. Results drop below as you type; on selection the input fills with the chosen label and the panel closes. */
export function InlineCombobox({
className,
size = "md",
placeholder = "Search...",
options = defaultOptions,
value,
defaultValue,
onValueChange,
}: ComboboxProps) {
const reduce = useReducedMotion() ?? false
const isControlled = value !== undefined
const [internal, setInternal] = React.useState<string | undefined>(defaultValue)
const current = isControlled ? value : internal
const selected = React.useMemo(
() => (current === undefined ? undefined : options.find((o) => o.value === current)),
[options, current]
)
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)
/* Filter as you type; show the whole list only on focus (not while editing). */
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()
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 (
<div ref={wrapperRef} data-slot="styled-combobox" className={cn(rootBase, panelWidth[size], className)}>
<div className={cn(inputRowBase, "rounded-lg border border-field-border px-3", triggerHeight[size])}>
<Search className="size-4 shrink-0 text-muted-foreground" />
<input
role="combobox"
aria-expanded={open}
aria-autocomplete="list"
value={inputValue}
placeholder={placeholder}
onFocus={() => {
setOpen(true)
setEditing(false)
}}
onChange={(e) => {
setEditing(true)
setOpen(true)
setQuery(e.target.value)
}}
onKeyDown={onInputKeyDown}
className={cn(inputBase, "text-foreground")}
/>
</div>
<AnimatePresence>
{open ? (
<motion.div
role="listbox"
className={cn(panelBase, panelWidth[size])}
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" }}
>
<div className={listBase}>
{filtered.length === 0 ? (
<p className={emptyBase}>No results found.</p>
) : (
filtered.map((option, i) => {
const isActive = i === active
const isSelected = option.value === current
return (
<div
key={option.value}
role="option"
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>
)
})
)}
</div>
</motion.div>
) : null}
</AnimatePresence>
</div>
)
}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.
Simple
A bordered trigger over a searchable list.
import { SimpleCombobox } from "@/components/ui/combobox-styled"
<SimpleCombobox />Glass
A frosted glass trigger and panel.
import { GlassCombobox } from "@/components/ui/combobox-styled"
<GlassCombobox />Tags
Multi-select with removable token tag chips.
import { TagsCombobox } from "@/components/ui/combobox-styled"
<TagsCombobox />Icon
Options carry a leading icon and a check.
import { IconCombobox } from "@/components/ui/combobox-styled"
<IconCombobox />Inline
The search input is the trigger; results drop below.
import { InlineCombobox } from "@/components/ui/combobox-styled"
<InlineCombobox />ai2 Styled combobox: 5 styled variations on the token system
The ai2 Styled combobox are a set of 5 decorative button variations from the styled layer of the @ai2 design system, built around searchable select menus. 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 and the panel appears instantly.
What is in the ai2 Styled combobox?
5 exports in one file: Simple, Glass, Tags, Icon and Inline. 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 and the panel appears instantly.
- 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 Styled combobox 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.