# Search Input

> Search field with a / focus shortcut, Esc to clear, loading state and a clear button.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/search-input
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/search-input.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

```bash
npm install lucide-react
```

## Usage

```tsx title="components/search-input-demo.tsx"
"use client"

import * as React from "react"

import { SearchInput } from "@/components/search-input"

const fruits = ["Apple", "Apricot", "Banana", "Blueberry", "Cherry", "Grape", "Lemon", "Mango", "Orange", "Peach", "Pear", "Pineapple"]

export default function SearchInputDemo() {
  const [query, setQuery] = React.useState("")
  const results = fruits.filter((f) => f.toLowerCase().includes(query.trim().toLowerCase()))
  return (
    <div className="grid w-full max-w-sm gap-3">
      <SearchInput value={query} onValueChange={setQuery} placeholder="Search fruit…" aria-label="Search fruit" />
      <ul className="grid min-h-40 content-start gap-1 text-sm" aria-label="Results">
        {results.map((fruit) => (
          <li key={fruit} className="rounded-md px-3 py-1.5 hover:bg-accent">
            {fruit}
          </li>
        ))}
        {results.length === 0 && <li className="px-3 py-1.5 text-muted-foreground">Nothing matches “{query}”.</li>}
      </ul>
      <p className="text-xs text-muted-foreground">Press / to focus, Esc to clear.</p>
    </div>
  )
}
```

## Source

```tsx title="components/search-input.tsx"
"use client"

import * as React from "react"
import { Loader2, Search, X } from "lucide-react"

import { cn } from "@/lib/utils"

export interface SearchInputProps extends Omit<React.ComponentProps<"input">, "value" | "defaultValue" | "onChange" | "type"> {
  value?: string
  defaultValue?: string
  onValueChange?: (value: string) => void
  /** Fired on Enter */
  onSearch?: (value: string) => void
  loading?: boolean
  /** Global key that focuses the field, set to "" to disable */
  shortcut?: string
}

export function SearchInput({
  value,
  defaultValue = "",
  onValueChange,
  onSearch,
  loading = false,
  shortcut = "/",
  placeholder = "Search…",
  className,
  onKeyDown,
  ...props
}: SearchInputProps) {
  const [internal, setInternal] = React.useState(defaultValue)
  const ref = React.useRef<HTMLInputElement>(null)
  const text = value ?? internal

  const set = (next: string) => {
    setInternal(next)
    onValueChange?.(next)
  }

  React.useEffect(() => {
    if (!shortcut) return
    const handler = (event: KeyboardEvent) => {
      if (event.key !== shortcut || event.metaKey || event.ctrlKey || event.altKey) return
      const target = event.target as HTMLElement | null
      if (target && (target.isContentEditable || /^(input|textarea|select)$/i.test(target.tagName))) return
      event.preventDefault()
      ref.current?.focus()
    }
    window.addEventListener("keydown", handler)
    return () => window.removeEventListener("keydown", handler)
  }, [shortcut])

  return (
    <div
      className={cn(
        "group relative flex h-10 w-full items-center rounded-full border border-input bg-background text-sm transition-[border-color,box-shadow] focus-within:border-ring focus-within:ring-4 focus-within:ring-ring/30",
        className,
      )}
    >
      <Search aria-hidden className="pointer-events-none absolute left-3.5 size-4 text-muted-foreground" />
      <input
        {...props}
        ref={ref}
        type="search"
        value={text}
        placeholder={placeholder}
        onChange={(event) => set(event.target.value)}
        onKeyDown={(event) => {
          onKeyDown?.(event)
          if (event.defaultPrevented) return
          if (event.key === "Enter") onSearch?.(text)
          if (event.key === "Escape" && text) {
            event.preventDefault()
            set("")
          }
        }}
        className="h-full min-w-0 flex-1 appearance-none bg-transparent pl-10 pr-20 outline-none placeholder:text-muted-foreground [&::-webkit-search-cancel-button]:hidden"
      />
      <div className="absolute right-2 flex items-center gap-1.5">
        {loading && <Loader2 aria-label="Searching" className="size-4 animate-spin text-muted-foreground" />}
        {text ? (
          <button
            type="button"
            aria-label="Clear search"
            onClick={() => {
              set("")
              ref.current?.focus()
            }}
            className="flex size-6 items-center justify-center rounded-full text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
          >
            <X className="size-3.5" />
          </button>
        ) : (
          shortcut && (
            <kbd className="pointer-events-none mr-1 hidden h-5 select-none items-center rounded border bg-muted px-1.5 font-mono text-[11px] text-muted-foreground sm:inline-flex">
              {shortcut}
            </kbd>
          )
        )}
      </div>
    </div>
  )
}
```
