Search selects
Five selects with a real filter field inside the panel: a plain filter, a search field with an icon, a live match counter, a clearable query and matched-substring highlighting. Each is self-contained (no radix, no native select), sized, token-driven, and closes on outside click or Escape.
Installation
The styled layer is free and installs like any other ai2 component.
Run the following command
npx shadcn@latest add @ai2/select-searchDependencies, the @ai2/tokens theme and the component file are installed together.
Install dependencies
npm install motion lucide-reactCopy the source
components/ui/select-search.tsx"use client"
import * as React from "react"
import { Check, ChevronDown, Search, X } from "lucide-react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { cn } from "@/lib/utils"
/* Search select family: 5 self-contained single-selects with REAL working
filtering. When the panel opens a search field appears at the top; the typed text
filters the option list with a case-insensitive substring match. The query and
selection state live in the ROOT component (the panel unmounts on close, but the
state is not lost). NO radix, NO native <select>, NO portal. Keyboard:
ArrowUp/Down moves the highlight, Enter selects, Escape closes and returns focus
to the trigger. Color comes ONLY from tokens. */
export type StyledSize = "sm" | "md" | "lg" | "xl"
const triggerHeight: Record<StyledSize, string> = {
sm: "h-8 text-sm",
md: "h-9 text-sm",
lg: "h-10 text-base",
xl: "h-12 text-base",
}
const panelWidth: Record<StyledSize, string> = {
sm: "w-48",
md: "w-56",
lg: "w-64",
xl: "w-72",
}
export interface StyledSelectOption {
value: string
label: string
}
export interface StyledSelectProps {
className?: string
size?: StyledSize
placeholder?: string
options?: StyledSelectOption[]
value?: string
defaultValue?: string
onValueChange?: (v: string) => void
}
const defaultOptions: StyledSelectOption[] = [
{ value: "next", label: "Next.js" },
{ value: "remix", label: "Remix" },
{ value: "astro", label: "Astro" },
{ value: "nuxt", label: "Nuxt" },
{ value: "svelte", label: "SvelteKit" },
{ value: "solid", label: "SolidStart" },
]
const triggerBase =
"inline-flex w-full shrink-0 select-none items-center justify-between gap-2 whitespace-nowrap rounded-lg border border-field-border bg-background px-3 font-medium text-foreground outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0 [&_i]:text-base [&_i]:leading-none"
const panelBase =
"absolute left-0 top-full z-50 mt-1 min-w-48 origin-top overflow-hidden rounded-xl border border-border bg-popover p-1.5 text-sm text-popover-foreground shadow-lg outline-none"
const optionBase =
"flex w-full cursor-default select-none items-center gap-2.5 rounded-md px-3 py-2 text-left text-sm text-popover-foreground outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-selected:bg-accent aria-selected:text-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&_i]:text-base [&_i]:leading-none"
const searchInputBase =
"w-full bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground"
const panelMotion = {
initial: { opacity: 0, scale: 0.96, y: -6 },
animate: { opacity: 1, scale: 1, y: 0 },
exit: { opacity: 0, scale: 0.96, y: -6 },
transition: { type: "spring" as const, stiffness: 340, damping: 26 },
}
/* Case-insensitive substring filtresi. */
function filterOptions(list: StyledSelectOption[], query: string) {
const q = query.trim().toLowerCase()
if (!q) return list
return list.filter((o) => o.label.toLowerCase().includes(q))
}
/* Eslesen alt dizgiyi <mark> ile isaretler. */
function markMatch(label: string, query: string): React.ReactNode {
const q = query.trim()
if (!q) return label
const at = label.toLowerCase().indexOf(q.toLowerCase())
if (at < 0) return label
return (
<>
{label.slice(0, at)}
<mark className="rounded-sm bg-[color-mix(in_oklab,var(--color-primary)_24%,transparent)] px-0.5 text-inherit">
{label.slice(at, at + q.length)}
</mark>
{label.slice(at + q.length)}
</>
)
}
interface SearchFlags {
/** Shows a magnifier icon in the search field. */
icon?: boolean
/** Shows a live match counter at the bottom of the panel. */
counter?: boolean
/** Shows a clear button in the search field. */
clearable?: boolean
/** Eslesen alt dizgiyi vurgular. */
highlight?: boolean
/** Arama alani placeholder metni. */
searchPlaceholder?: string
}
function BaseSearchSelect({
className,
size = "md",
placeholder = "Select...",
options,
value,
defaultValue,
onValueChange,
icon,
counter,
clearable,
highlight,
searchPlaceholder = "Search...",
}: StyledSelectProps & SearchFlags) {
const list = options ?? defaultOptions
const reduce = useReducedMotion()
const uid = React.useId()
const listId = `${uid}-listbox`
const optionId = (i: number) => `${uid}-option-${i}`
const [open, setOpen] = React.useState(false)
const [query, setQuery] = React.useState("")
const [active, setActive] = React.useState(0)
const [internal, setInternal] = React.useState<string | undefined>(defaultValue)
const wrapperRef = React.useRef<HTMLDivElement>(null)
const triggerRef = React.useRef<HTMLButtonElement>(null)
const inputRef = React.useRef<HTMLInputElement>(null)
const isControlled = value !== undefined
const current = isControlled ? value : internal
const filtered = filterOptions(list, query)
const selectedLabel = list.find((o) => o.value === current)?.label
const close = React.useCallback(() => {
setOpen(false)
triggerRef.current?.focus()
}, [])
const select = React.useCallback(
(next: string) => {
if (!isControlled) setInternal(next)
onValueChange?.(next)
setOpen(false)
setQuery("")
triggerRef.current?.focus()
},
[isControlled, onValueChange],
)
React.useEffect(() => {
if (!open) return
const onPointer = (e: PointerEvent) => {
const node = wrapperRef.current
if (node && e.target instanceof Node && !node.contains(e.target)) setOpen(false)
}
window.addEventListener("pointerdown", onPointer)
return () => window.removeEventListener("pointerdown", onPointer)
}, [open])
React.useEffect(() => {
if (!open) return
const id = window.requestAnimationFrame(() => inputRef.current?.focus())
return () => window.cancelAnimationFrame(id)
}, [open])
const onListKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault()
close()
return
}
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault()
if (filtered.length === 0) return
const dir = e.key === "ArrowDown" ? 1 : -1
setActive((i) => (i + dir + filtered.length) % filtered.length)
return
}
if (e.key === "Enter") {
e.preventDefault()
const opt = filtered[active]
if (opt) select(opt.value)
}
}
const onTriggerKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
if (!open) return
e.preventDefault()
close()
return
}
if (e.key === "ArrowDown" || e.key === "ArrowUp" || e.key === "Enter" || e.key === " ") {
if (open) return
e.preventDefault()
setQuery("")
setActive(0)
setOpen(true)
}
}
const fade = { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 } }
const anim = reduce ? fade : panelMotion
return (
<div
ref={wrapperRef}
data-slot="styled-select"
className={cn("relative inline-flex w-56 max-w-full", panelWidth[size], className)}
>
<button
ref={triggerRef}
type="button"
data-slot="styled-select-trigger"
role="combobox"
aria-expanded={open}
aria-haspopup="listbox"
aria-controls={listId}
className={cn(triggerBase, triggerHeight[size])}
onClick={() => {
if (!open) {
setQuery("")
setActive(0)
}
setOpen(!open)
}}
onKeyDown={onTriggerKeyDown}
>
<span className={cn("truncate", selectedLabel === undefined && "text-muted-foreground")}>
{selectedLabel ?? placeholder}
</span>
<ChevronDown
className={cn("shrink-0 opacity-70 transition-transform duration-200", open && "rotate-180")}
/>
</button>
<AnimatePresence>
{open ? (
<motion.div
data-slot="styled-select-content"
className={cn(panelBase, panelWidth[size])}
initial={anim.initial}
animate={anim.animate}
exit={anim.exit}
transition={reduce ? { duration: 0.14 } : panelMotion.transition}
>
<div className="flex items-center gap-2 rounded-md border border-field-border bg-background px-2.5 py-1.5 focus-within:ring-[3px] focus-within:ring-ring/50">
{icon ? <Search className="size-4 shrink-0 text-muted-foreground" /> : null}
<input
ref={inputRef}
type="text"
aria-label="Filter options"
aria-controls={listId}
aria-activedescendant={filtered.length > 0 ? optionId(active) : undefined}
autoComplete="off"
className={searchInputBase}
placeholder={searchPlaceholder}
value={query}
onChange={(e) => {
setQuery(e.target.value)
setActive(0)
}}
onKeyDown={onListKeyDown}
/>
{clearable && query.length > 0 ? (
<button
type="button"
aria-label="Clear filter"
className="inline-flex size-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 [&_svg]:size-3.5 [&_svg]:shrink-0 [&_i]:text-xs [&_i]:leading-none"
onClick={() => {
setQuery("")
setActive(0)
inputRef.current?.focus()
}}
>
<X />
</button>
) : null}
</div>
<div
id={listId}
role="listbox"
className="mt-1.5 flex max-h-56 flex-col overflow-auto"
onKeyDown={onListKeyDown}
>
{filtered.length === 0 ? (
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
No results found.
</div>
) : (
filtered.map((option, i) => {
const isSelected = option.value === current
return (
<button
key={option.value}
id={optionId(i)}
type="button"
role="option"
tabIndex={-1}
aria-selected={isSelected}
className={cn(optionBase, i === active && "bg-accent text-accent-foreground")}
onPointerEnter={() => setActive(i)}
onClick={() => select(option.value)}
>
<span className="flex size-4 shrink-0 items-center justify-center text-primary">
{isSelected ? <Check /> : null}
</span>
<span className="truncate">
{highlight ? markMatch(option.label, query) : option.label}
</span>
</button>
)
})
)}
</div>
{counter ? (
<div
aria-live="polite"
className="mt-1.5 border-t border-border px-3 pb-0.5 pt-2 text-xs text-muted-foreground"
>
{filtered.length} of {list.length} match
</div>
) : null}
</motion.div>
) : null}
</AnimatePresence>
</div>
)
}
/* Filter: sade bir filtre alani panelin ustunde. */
export function FilterSelect(props: StyledSelectProps) {
return <BaseSearchSelect {...props} searchPlaceholder="Filter..." />
}
/* Type: lupe ikonlu, yazmaya davet eden arama alani. */
export function TypeSelect(props: StyledSelectProps) {
return <BaseSearchSelect {...props} icon searchPlaceholder="Type to filter..." />
}
/* Live: eslesme sayaci panelin altinda canli guncellenir. */
export function LiveSelect(props: StyledSelectProps) {
return <BaseSearchSelect {...props} icon counter searchPlaceholder="Search..." />
}
/* Clear: the search field carries a clear button. */
export function ClearSelect(props: StyledSelectProps) {
return <BaseSearchSelect {...props} icon clearable searchPlaceholder="Search..." />
}
/* Highlight: eslesen alt dizgi option satirinda vurgulanir. */
export function HighlightSelect(props: StyledSelectProps) {
return <BaseSearchSelect {...props} icon clearable highlight searchPlaceholder="Search..." />
}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.
Filter
A plain filter field above the list.
import { FilterSelect } from "@/components/ui/select-search"
<FilterSelect />Type
A search field with a leading icon.
import { TypeSelect } from "@/components/ui/select-search"
<TypeSelect />Live
A live match counter under the list.
import { LiveSelect } from "@/components/ui/select-search"
<LiveSelect />Clear
A clear button resets the query.
import { ClearSelect } from "@/components/ui/select-search"
<ClearSelect />Highlight
The matched substring is highlighted.
import { HighlightSelect } from "@/components/ui/select-search"
<HighlightSelect />ai2 Search selects: 5 styled variations on the token system
The ai2 Search selects are a set of 5 decorative button variations from the styled layer of the @ai2 design system, built around single-choice selects with a filterable option list. 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 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 Search selects?
5 exports in one file: Filter, Type, Live, Clear and Highlight. 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 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 Search selects 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.