Search Field
A search input with a / shortcut, recent searches on focus, live match count and a clear button that pops in.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/search-field.json'use client'
import { useEffect, useId, useRef, useState } from 'react'
import './search-field.css'
export interface SearchFieldProps {
items: string[]
recent?: string[]
placeholder?: string
shortcut?: boolean
onSearch?: (q: string) => void
fill?: 'dark' | 'light'
className?: string
}
function hl(s: string, q: string) {
const i = s.toLowerCase().indexOf(q.toLowerCase())
if (!q || i < 0) return s
return (<>{s.slice(0, i)}<mark>{s.slice(i, i + q.length)}</mark>{s.slice(i + q.length)}</>)
}
/** Search input with shortcut, recents and live result count. */
export function SearchField({ items, recent: recentInit = [], placeholder = 'Search', shortcut = true, onSearch, fill = 'dark', className }: SearchFieldProps) {
const [q, setQ] = useState('')
const [open, setOpen] = useState(false)
const [act, setAct] = useState(0)
const [recent, setRecent] = useState(recentInit)
const ref = useRef<HTMLInputElement>(null)
const id = useId()
useEffect(() => {
if (!shortcut) return
const h = (e: KeyboardEvent) => {
const t = e.target as HTMLElement
if (e.key === '/' && !/INPUT|TEXTAREA/.test(t.tagName) && !t.isContentEditable) { e.preventDefault(); ref.current?.focus() }
}
addEventListener('keydown', h)
return () => removeEventListener('keydown', h)
}, [shortcut])
const list = q ? items.filter((s) => s.toLowerCase().includes(q.toLowerCase())).slice(0, 6) : recent
const choose = (s: string) => {
setQ(s); setOpen(false); onSearch?.(s)
setRecent((r) => [s, ...r.filter((x) => x !== s)].slice(0, 4))
}
return (
<div className={['sd-sf', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-sf__box">
<svg viewBox="0 0 16 16" className="sd-sf__ico" aria-hidden><circle cx="7" cy="7" r="4.5" /><path d="m10.5 10.5 3 3" /></svg>
<input
ref={ref}
className="sd-sf__in"
role="combobox"
aria-expanded={open}
aria-controls={`${id}-l`}
aria-activedescendant={open && list[act] ? `${id}-${act}` : undefined}
aria-label={placeholder}
placeholder={placeholder}
value={q}
onFocus={() => setOpen(true)}
onBlur={() => setTimeout(() => setOpen(false), 120)}
onChange={(e) => { setQ(e.target.value); setAct(0); setOpen(true) }}
onKeyDown={(e) => {
if (e.key === 'ArrowDown') (e.preventDefault(), setAct((a) => Math.min(list.length - 1, a + 1)))
else if (e.key === 'ArrowUp') (e.preventDefault(), setAct((a) => Math.max(0, a - 1)))
else if (e.key === 'Enter') choose(list[act] ?? q)
else if (e.key === 'Escape') q ? setQ('') : ref.current?.blur()
}}
/>
{q ? (
<button type="button" className="sd-sf__clr" aria-label="Clear search" onClick={() => { setQ(''); ref.current?.focus() }}>
<svg viewBox="0 0 12 12" aria-hidden><path d="m3 3 6 6M9 3 3 9" /></svg>
</button>
) : shortcut ? <kbd className="sd-sf__kbd" aria-hidden>/</kbd> : null}
</div>
{open && (
<div className="sd-sf__pop">
<div className="sd-sf__head"><span>{q ? 'Results' : 'Recent'}</span>{q && <span>{list.length} found</span>}</div>
<div role="listbox" id={`${id}-l`}>
{list.map((s, i) => (
<div key={s} id={`${id}-${i}`} role="option" aria-selected={i === act} className="sd-sf__opt" onMouseEnter={() => setAct(i)} onMouseDown={(e) => { e.preventDefault(); choose(s) }}>
{q ? hl(s, q) : <>{s}<button type="button" className="sd-sf__x" aria-label={`Remove ${s}`} tabIndex={-1} onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); setRecent((r) => r.filter((x) => x !== s)) }}>×</button></>}
</div>
))}
</div>
{!list.length && <div className="sd-sf__none">{q ? `No results for “${q}”` : 'No recent searches'}</div>}
<span className="sr" aria-live="polite">{q ? `${list.length} results` : ''}</span>
</div>
)}
</div>
)
}.sd-sf { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-sf[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-sf button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-sf :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-sf .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-sf { position: relative; width: min(100%, 320px); align-self: start; margin-top: 30px; }
.sd-sf__box { display: flex; align-items: center; gap: 8px; height: 44px; padding: 0 8px 0 13px; border-radius: 14px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); transition: box-shadow 200ms; }
.sd-sf__box:focus-within { box-shadow: inset 0 0 0 1.5px var(--fg); }
.sd-sf__ico { width: 16px; height: 16px; flex: none; color: var(--mute); fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; }
.sd-sf__in { flex: 1; min-width: 0; height: 100%; background: none; border: 0; outline: 0; color: var(--fg); font: inherit; font-size: 14.5px; }
.sd-sf__in::placeholder { color: var(--mute); }
.sd-sf__kbd { font: 500 11px/1 ui-monospace, monospace; padding: 4px 7px; border-radius: 6px; box-shadow: inset 0 0 0 1px var(--line); color: var(--mute); }
.sd-sf__clr { display: grid; place-items: center; width: 26px; height: 26px; border-radius: 99px; background: var(--soft) !important; animation: sd-sf-pop 260ms cubic-bezier(0.3, 1.6, 0.4, 1); }
.sd-sf__clr svg { width: 11px; height: 11px; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; }
@keyframes sd-sf-pop { from { transform: scale(0.3); opacity: 0; } }
.sd-sf__pop { position: absolute; left: 0; right: 0; top: 50px; padding: 6px; border-radius: 14px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line), 0 18px 40px -16px rgb(0 0 0 / 0.5); animation: sd-sf-drop 200ms ease; z-index: 3; }
@keyframes sd-sf-drop { from { opacity: 0; transform: translateY(-4px); } }
.sd-sf__head { display: flex; justify-content: space-between; padding: 6px 8px; font-size: 11.5px; color: var(--mute); }
.sd-sf__opt { display: flex; align-items: center; gap: 8px; padding: 8px; border-radius: 9px; font-size: 13.5px; cursor: pointer; }
.sd-sf__opt[aria-selected='true'] { background: var(--soft); }
.sd-sf__opt mark { background: none; color: var(--fg); font-weight: 650; text-decoration: underline; text-underline-offset: 3px; }
.sd-sf__opt span { flex: 1; color: var(--mute); }
.sd-sf__x { margin-left: auto; color: var(--mute); width: 20px; height: 20px; display: grid; place-items: center; border-radius: 6px; }
.sd-sf__x:hover { color: var(--fg); background: var(--soft); }
.sd-sf__none { padding: 14px 8px; font-size: 13px; color: var(--mute); }
@media (prefers-reduced-motion: reduce) { .sd-sf *, .sd-sf *::before, .sd-sf *::after { animation: none !important; transition: none !important; } }Overview
Press / anywhere to focus it. Empty, it shows recent searches you can pick or remove; typing filters a list and shows how many matched, with matches highlighted.
The clear button scales in only when there is text, and Escape clears then blurs.
When to use
- Filtering lists, docs search, settings search.
When not to use
- Global app-wide actions: use a command palette.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/search-field.json$
npx shadcn@latest add https://sundaeui.dev/r/search-field.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/search-field.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/search-field.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy search-field.tsx and search-field.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { SearchField } from '@/components/sundae/search-field'
<SearchField />Props
| Prop | Type | Default |
|---|---|---|
items*Searchable strings. | string[] | — |
recentRecent searches shown on focus. | string[] | — |
placeholderPlaceholder. | string | 'Search' |
shortcutEnable the / shortcut. | boolean | true |
onSearchCalled on Enter. | (q: string) => void | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- A combobox with a listbox of results; arrow keys move the active option.
- Match counts are announced politely.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-sf; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
Keyboard
- /
- Focus search
- ↑ / ↓
- Move through results
- Enter
- Choose
- Esc
- Clear, then close
FAQ
- Is Search Field free?
- Yes. Search Field is free for personal and commercial projects, with full source on this page.
- How do I install Search Field?
- Run npx shadcn@latest add https://sundaeui.dev/r/search-field.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Search Field support keyboard and screen readers?
- A combobox with a listbox of results; arrow keys move the active option. Match counts are announced politely.
- Does Search Field respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All forms →OTP Field
Forms · RefinedCombobox
Forms · UnderstatedRange Slider
Forms · Understated- Pro
Wheel Picker
Forms · Refined Password Strength
Forms · RefinedStar Rating
Forms · Refined