Skip to the content

ComponentsForm

Search field

A search box with a search icon, a clear button and an optional spinner, inside a search landmark.

Import

import { SearchField } from "@booleanpress/ui/search-field"

Usage

SearchField is the library's Input in an InputGroup, inside a search landmark (role="search"). Enter calls onSearch with the text.

import { SearchField } from "@booleanpress/ui/search-field"

export function LogSearch({ onSearch }: { onSearch: (text: string) => void }) {
  return <SearchField aria-label="Search delivery logs" placeholder="Search delivery logs" onSearch={onSearch} />
}

It is uncontrolled with defaultValue, or controlled with value and onChange; filter as people type in onChange, or on Enter in onSearch. A ร— button shows while there is text: it empties the field, calls onChange and keeps the focus in the field. Escape does the same; a second Escape, on the empty field, leaves it. loading shows a spinner at the end and marks the field busy while results load. size is sm (28 px), default (35 px) or lg (42 px), and variant="filled" fills the field grey; both default to the provider's controlSize and fieldVariant. className goes on the landmark round the field; the other attributes go on the input. It is not a form of its own, so it can sit inside one, such as a settings page: Enter searches and never sends that form, and with name the text is submitted with it.

Examples

Basic

A named search box with a placeholder.

import { SearchField } from "@booleanpress/ui/search-field"

export default function SearchFieldBasic() {
  return (
    <div className="w-full max-w-sm">
      <SearchField aria-label="Search delivery logs" placeholder="Search delivery logs" />
    </div>
  )
}

With results count

The list filters as people type, and a status line under the field says how many match.

import { useState } from "react"
import { SearchField } from "@booleanpress/ui/search-field"

const MAILERS = ["Amazon SES", "Brevo", "Mailgun", "Postmark", "SendGrid", "SMTP2GO", "SparkPost", "Zoho Mail"]

export default function SearchFieldResultsCount() {
  const [query, setQuery] = useState("")
  const matches = MAILERS.filter((name) => name.toLowerCase().includes(query.trim().toLowerCase()))

  return (
    <div className="flex w-full max-w-sm flex-col gap-2">
      <SearchField
        aria-label="Search mailers"
        placeholder="Search mailers"
        value={query}
        onChange={(event) => setQuery(event.target.value)}
        aria-describedby="mailer-count"
      />
      <p id="mailer-count" role="status" className="text-xs text-muted-foreground">
        {matches.length} of {MAILERS.length} mailers
      </p>
      <ul className="flex flex-col gap-1 text-sm">
        {matches.map((name) => (
          <li key={name}>{name}</li>
        ))}
      </ul>
    </div>
  )
}

Loading

loading shows a spinner at the end; here it shows until you type, then for 800 ms after each change.

import { useEffect, useRef, useState } from "react"
import { SearchField } from "@booleanpress/ui/search-field"

export default function SearchFieldLoading() {
  const [loading, setLoading] = useState(true)
  const timer = useRef<ReturnType<typeof setTimeout>>(undefined)
  useEffect(() => () => clearTimeout(timer.current), [])

  return (
    <div className="w-full max-w-sm">
      <SearchField
        aria-label="Search tickets"
        placeholder="Search tickets"
        defaultValue="invoice"
        loading={loading}
        onChange={() => {
          // A pretend lookup: the results arrive 800 ms after the last change.
          setLoading(true)
          clearTimeout(timer.current)
          timer.current = setTimeout(() => setLoading(false), 800)
        }}
      />
    </div>
  )
}

Sizes

sm is 28 px tall with 12 px text and icon, default 35 px with 14 px, lg 42 px with 16 px.

import { SearchField } from "@booleanpress/ui/search-field"

export default function SearchFieldSizes() {
  return (
    <div className="flex w-full max-w-sm flex-col gap-4">
      <SearchField size="sm" aria-label="Search customers, small" placeholder="Small" />
      <SearchField aria-label="Search customers, normal" placeholder="Normal" />
      <SearchField size="lg" aria-label="Search customers, large" placeholder="Large" />
    </div>
  )
}

Filled

variant="filled" draws the grey --field-filled fill, which stays on hover and focus.

import { SearchField } from "@booleanpress/ui/search-field"

export default function SearchFieldFilled() {
  return (
    <div className="w-full max-w-sm">
      <SearchField variant="filled" aria-label="Search organisations" placeholder="Search organisations" />
    </div>
  )
}

Disabled

The text shows; the field and its clear button cannot be used.

import { SearchField } from "@booleanpress/ui/search-field"

export default function SearchFieldDisabled() {
  return (
    <div className="w-full max-w-sm">
      <SearchField disabled aria-label="Search API keys" defaultValue="live" />
    </div>
  )
}

Accessibility

Semantics
A div with role="search" (a search landmark) round a native input type="search" (a searchbox). The clear button is a native button; the spinner is a status named by the provider string loading, and the input carries aria-busy while it shows.
Labels
Name it with aria-label, or a Label whose htmlFor is its id. With neither, the field and the landmark are named by the provider string search. With more than one search on a page, give each its own name. The clear button is named by the provider string clear.
Focus
The input is a tab stop, then the clear button while it shows. Focus turns the edge --ring. Clearing keeps the focus in the input.
Known limits
  • Inside a dialog, the dialog's own Escape handling runs first and closes it.
  • A results count is the app's to show; put it in a role="status" element so it is announced.

Keyboard

Keyboard
KeyBehaviour
EnterRuns the search: calls onSearch with the text. It never submits a form round the field.
EscapeEmpties the field and keeps the focus in it; on an empty field, leaves the field.
TabMoves from the input to the clear button while it shows.

API

SearchField

SearchField props
PropTypeDefaultDescription
keyFilterInputKeyFilterLets only some characters in: int, num, money, hex, alpha, alphanum or a regular expression. Typed and pasted text it refuses is not inserted.
loadingbooleanfalseShows a spinner at the end while results load, and marks the field busy.
onSearch((value: string) => void)Called with the text when the search is submitted with Enter.
size"default" | "sm" | "lg"The field's size: 28, 35 or 42 px tall. Defaults to the provider's controlSize.
variant"default" | "filled"filled draws the grey --field-filled fill. Defaults to the provider's fieldVariant.

Every part takes className, merged with its defaults by cn(), and ref, which reaches the element it renders.

Data attributes: data-slot="search-field" (SearchField), and data-disabled.

Provider strings: search (BooleanUIProvider's strings).

Theming

The field is InputGroup's: --field, --control, --ring, --invalid and --field-disabled. The search icon and the clear ร— are --control-hover; the spinner is --muted-foreground.

The tokens its classes read. Change their values in your theme, and it follows.

Theme tokens
TokenUsed for
--muted-foregroundtext