Skip to content
Caseroom

Components · Forms

Search field

A search input in a role=search form, with a / shortcut and a clear button.

9.7% of the 2,062 sites in The UI Index have a search (200 sites).

Both previews render the same markup; the HTML tab below is that markup without a framework.

Install

React
npx shadcn@latest add https://www.themes.dev/caseroom/r/search-field.json
Astro
npx shadcn@latest add https://www.themes.dev/caseroom/r/astro/search-field.json

Or copy the files below into your project. They need clsx, tailwind-merge, and the Caseroom theme. The HTML needs only the compiled CSS and the script.

search-field.client.ts
// Search field behaviour, shared by the Astro, React and HTML versions: "/" (outside any field) focuses the page's
// first visible [data-search] input; the clear button empties the field, fires input, and puts focus back in it.
declare global {
  interface Window {
    __caseroomSearch?: boolean;
  }
}

if (typeof window !== 'undefined' && !window.__caseroomSearch) {
  window.__caseroomSearch = true;
  document.addEventListener('keydown', (e) => {
    if (e.key !== '/' || e.metaKey || e.ctrlKey || e.altKey) return;
    const t = e.target as HTMLElement;
    if (t.closest('input, textarea, select, [contenteditable=""], [contenteditable=true]')) return;
    // The first one you can see (a page may keep others in a closed panel or menu).
    const input = [...document.querySelectorAll<HTMLInputElement>('input[data-search]')].find((i) => i.getClientRects().length > 0);
    if (!input) return;
    e.preventDefault();
    input.focus();
  });
  document.addEventListener('click', (e) => {
    const clear = (e.target as Element).closest<HTMLButtonElement>('[data-search-clear]');
    if (!clear) return;
    const input = clear.parentElement?.querySelector<HTMLInputElement>('input[data-search]');
    if (!input) return;
    input.value = '';
    input.dispatchEvent(new Event('input', { bubbles: true }));
    input.focus();
  });
}

export {};
search-field.tsx
import { cn } from '@/lib/utils';
import './search-field.client';
import { CLEAR_PATH, search, SEARCH_PATH } from './search-field.variants';

export interface SearchFieldProps {
  id: string;
  /** The accessible name, e.g. "Search the docs". */
  label: string;
  placeholder?: string;
  /** Where the form goes (GET), e.g. "/search". */
  action?: string;
  name?: string;
  defaultValue?: string;
  className?: string;
}

/** A search field in a role=search form, with a "/" shortcut and a clear button. */
export function SearchField({ id, label, placeholder = 'Search', action, name = 'q', defaultValue, className }: SearchFieldProps) {
  return (
    <form role="search" action={action} className={cn(search.root, className)}>
      <label htmlFor={id} className="sr-only">{label}</label>
      <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden="true" className={search.icon}><path d={SEARCH_PATH} /></svg>
      <input type="search" id={id} name={name} defaultValue={defaultValue} placeholder={placeholder} autoComplete="off" data-search="" className={search.input} />
      <kbd className={search.hint} aria-hidden="true">/</kbd>
      <button type="button" data-search-clear="" aria-label="Clear search" className={search.clear}>
        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden="true"><path d={CLEAR_PATH} /></svg>
      </button>
    </form>
  );
}
search-field.variants.ts
import { fieldClasses } from '../input/input.variants';

/** A search field in a role=search form: magnifier, a native search input, a "/" shortcut hint that gives way while
 *  you type, and a clear button that appears once there is text. "/" anywhere outside a field focuses it. */
export const search = {
  root: 'relative w-full',
  icon: 'pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground',
  input: `${fieldClasses} peer h-9 py-1 pr-10 pl-9 [&::-webkit-search-cancel-button]:appearance-none`,
  hint: 'pointer-events-none absolute top-1/2 right-2 inline-flex h-5 min-w-5 -translate-y-1/2 items-center justify-center rounded border bg-muted px-1 font-sans text-[11px] text-muted-foreground peer-focus:hidden peer-[:not(:placeholder-shown)]:hidden',
  clear:
    'absolute top-1/2 right-1.5 hidden size-7 -translate-y-1/2 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground active:bg-pressed peer-[:not(:placeholder-shown)]:inline-flex [&_svg]:size-3.5',
};
export const SEARCH_PATH = 'M7 12.5a5.5 5.5 0 1 0 0-11 5.5 5.5 0 0 0 0 11zM11 11l3.5 3.5';
export const CLEAR_PATH = 'M4 4l8 8M12 4l-8 8';