a search field: a magnifier, clear, Esc, and the matched letters lit
npx shadcn add @ag/searchAlready installed? Preview an update
npx shadcn add @ag/search --dry-runYour installed source stays unchanged until you update it. How updates work
Latest changes · 2026-10-11
- Changed: the
shortcutthat focuses it goes through key-router: typed into a field, "/" stays a character there, while a ⌘ combination (mod+k) now reaches it from a field too; a lone-character shortcut matches however the layout types it. - Changed: the shortcut's place on the page is the field itself, so a search inside an
inertsubtree leaves the shortcut to the page's own, and the one under the pointer goes first.
also called search bar
shelf controls
uses fuzzy, input, kbd, keys, key-router
used by command, tree, chip-input, people-picker, data-view, country-picker, icon-picker
the demo above, verbatim
"use client"
import * as React from "react"
import { Sample } from "@/app/samples"
import { fuzzyFilter } from "@/registry/base-nova/lib/fuzzy"
import { Highlight, Search } from "@/registry/base-nova/ui/search"
// #region list
const TAGS = [
"people/a person smiling",
"people/a face",
"people/two people kissing",
"places/a kitchen",
"places/a living room",
"places/Café de Flore",
"things/a red car",
"things/a dog running",
"light/golden hour",
"light/neon at night",
]
function List() {
const [q, setQ] = React.useState("")
const found = fuzzyFilter(TAGS, q, (t) => t)
return (
<div className="flex w-80 flex-col gap-2">
<Search
value={q}
onChange={setQ}
label="search tags"
shortcut="/"
placeholder="search tags"
/>
<ul className="flex flex-col text-[13px]">
{found.map((f) => (
<li
key={f.item}
className="flex items-center gap-2 rounded-md px-2 py-1 hover:bg-foreground/5"
>
<span className="min-w-0 flex-1 truncate text-foreground/70">
<Highlight text={f.item} ranges={f.ranges[0]} />
</span>
{q && (
<span className="font-mono text-[10px] text-foreground/35 tabular-nums">
{f.score.toFixed(0)}
</span>
)}
</li>
))}
{found.length === 0 && (
<li className="px-2 py-1 text-foreground/40">nothing called that</li>
)}
</ul>
</div>
)
}
// #endregion
export default function SearchDemo() {
return (
<Sample
name="list"
title="letters in order, not side by side"
description="“ppl smlg” finds “a person smiling”, “cafe” finds “Café”. every word must be found, in any order; runs and word starts rank first. / focuses the field, Esc empties it."
>
<List />
</Sample>
)
}the source, every file an install writes
"use client"
import { Search as SearchIcon, X } from "lucide-react"
import * as React from "react"
import { cn } from "@/lib/utils"
import { fuzzy, type Range, segments } from "@/registry/base-nova/lib/fuzzy"
import { pressed } from "@/registry/base-nova/lib/keys"
import { InputGroup, type InputSize } from "@/registry/base-nova/ui/input"
import { Kbd } from "@/registry/base-nova/ui/kbd"
import { useKeys } from "@/registry/base-nova/ui/key-router"
// SEARCH, drawn once: the field (`Search`: the input's well with a magnifier, a clear
// button once something is typed, Esc empties it, an optional key that focuses it from
// anywhere on the page) and the match drawn in a result (`Highlight`: the letters the
// query lit, from lib/fuzzy, which every search here matches with). A list filters
// itself with `fuzzyFilter` and draws each row's text through `Highlight`.
export interface SearchProps {
value: string
onChange: (value: string) => void
placeholder?: string
/** what it searches, for a screen reader ("search tags") */
label: string
size?: InputSize
/** a key that focuses it from anywhere on the page ("/", "mod+k") */
shortcut?: string
/** Enter: take the first result */
onEnter?: () => void
/** ↓ from the field: into the results */
onDown?: () => void
autoFocus?: boolean
className?: string
ref?: React.Ref<HTMLInputElement>
}
/** Whether a keydown is the shortcut ("/", "mod+k"), `mod` being ⌘ or ⌃ whichever
* the hand reached for; a lone character however the layout types it (a German "/"
* is ⇧7). */
const isShortcut = (e: KeyboardEvent, shortcut: string) =>
pressed(shortcut, e, "mac") ||
pressed(shortcut, e, "other") ||
([...shortcut].length === 1 &&
e.key === shortcut &&
!e.metaKey &&
!e.ctrlKey &&
!e.altKey)
export function Search({
value,
onChange,
placeholder = "search",
label,
size = "sm",
shortcut,
onEnter,
onDown,
autoFocus,
className,
ref,
}: SearchProps) {
const own = React.useRef<HTMLInputElement>(null)
React.useImperativeHandle(ref, () => own.current as HTMLInputElement)
// The shortcut, on the page's key router: typed into a field it is the field's
// ("/" is a character there), while a ⌘ combination still reaches it. Its root is the
// field, so a search inside an inert subtree (a picture of one) never takes it.
useKeys(
(e) => {
if (!shortcut || !isShortcut(e, shortcut)) return false
own.current?.focus()
own.current?.select()
return true
},
{ enabled: !!shortcut, root: own },
)
return (
<InputGroup
ref={own}
size={size}
value={value}
placeholder={placeholder}
aria-label={label}
spellCheck={false}
autoComplete="off"
autoFocus={autoFocus}
className={className}
start={<SearchIcon />}
end={
value ? (
<button
type="button"
aria-label="clear"
onClick={() => {
onChange("")
own.current?.focus()
}}
className="grid cursor-pointer place-items-center hover:text-foreground"
>
<X />
</button>
) : shortcut ? (
<Kbd keys={shortcut} />
) : undefined
}
onChange={(e) => onChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Escape" && value) {
e.stopPropagation()
onChange("")
}
if (e.key === "Enter" && onEnter) {
e.preventDefault()
onEnter()
}
if (e.key === "ArrowDown" && onDown) {
e.preventDefault()
onDown()
}
}}
/>
)
}
/** A text with the letters a match lit marked: by its ranges, or by a query matched
* here (lib/fuzzy). */
export function Highlight({
text,
ranges,
query,
className,
}: {
text: string
ranges?: readonly Range[]
query?: string
className?: string
}) {
const lit = ranges ?? (query ? (fuzzy(query, text)?.ranges ?? []) : [])
if (lit.length === 0) return text
return (
<>
{segments(text, lit).map((s, i) =>
s.hit ? (
<mark
// biome-ignore lint/suspicious/noArrayIndexKey: segments are positional
key={i}
className={cn(
"rounded-[2px] bg-transparent font-semibold text-foreground underline decoration-foreground/40 underline-offset-2",
className,
)}
>
{s.text}
</mark>
) : (
s.text
),
)}
</>
)
}