← ui

search

ui

a search field: a magnifier, clear, Esc, and the matched letters lit

npx shadcn add @ag/search
Already installed? Preview an update
npx shadcn add @ag/search --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-10-11
  • Changed: the shortcut that 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 inert subtree leaves the shortcut to the page's own, and the one under the pointer goes first.
Updated 2026-10-11 · release notes

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

search.demo.tsx
"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

search.tsx
"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
        ),
      )}
    </>
  )
}