Docs
Systhema Design (opens in new tab)
Unreleased

SearchField

A search input with a trailing magnifier on the form input surface, controlled or uncontrolled.

On this page

SearchField is a single-line type="search" input with a trailing magnifier icon. It is built like a select: a wrapper holds the input and an icon overlay, on the same input tokens, so a search box and a select sit side by side identically. It holds no state and does no searching; you own the value and the submit.

SearchField
import { SearchField } from '@systhemaui/next'

export default function Demo() {
  return (
    <div role="search" style={{ width: '100%', maxWidth: 480 }}>
      <SearchField name="q" placeholder="Search the docs" aria-label="Search the docs" />
    </div>
  )
}

ImportLink to this section

import { SearchField } from '@systhemaui/next'

In a React app without Next.js, import it from @systhemaui/react.

ExamplesLink to this section

ControlledLink to this section

Every native <input> prop, value and onChange included, goes to the input, and the ref points at the input too.

Filtering a list
  • Accordion
  • Button
  • Card
  • Carousel
  • Chip
  • Gallery
  • Heading
'use client'

import { useState } from 'react'
import { List, ListItem, Paragraph, SearchField } from '@systhemaui/next'

const components = ['Accordion', 'Button', 'Card', 'Carousel', 'Chip', 'Gallery', 'Heading']

export default function Demo() {
  const [query, setQuery] = useState('')
  const matches = components.filter((name) => name.toLowerCase().includes(query.toLowerCase()))

  return (
    <div style={{ display: 'grid', gap: 16, width: '100%', maxWidth: 480 }}>
      <SearchField
        value={query}
        onChange={(event) => setQuery(event.target.value)}
        placeholder="Filter components"
        aria-label="Filter components"
      />
      {matches.length > 0 ? (
        <List>
          {matches.map((name) => (
            <ListItem key={name}>{name}</ListItem>
          ))}
        </List>
      ) : (
        <Paragraph type="small">No component matches “{query}”.</Paragraph>
      )}
    </div>
  )
}

Next to a selectLink to this section

The search field and FormSelect share padding, border, radius and type, so a filter bar lines up without extra CSS.

Filter bar
import { FormGroup, FormRow, FormSelect, Option, SearchField } from '@systhemaui/next'

export default function Demo() {
  return (
    <FormRow style={{ width: '100%', maxWidth: 640 }}>
      <FormGroup>
        <SearchField name="filter-q" placeholder="Search posts" aria-label="Search posts" />
      </FormGroup>
      <FormGroup>
        <FormSelect name="filter-category" defaultValue="all" aria-label="Category">
          <Option value="all">All categories</Option>
          <Option value="news">News</Option>
          <Option value="guides">Guides</Option>
        </FormSelect>
      </FormGroup>
    </FormRow>
  )
}

Custom iconLink to this section

icon replaces the default magnifier. Without it, the wrapper renders an empty .search-field-icon that the CSS masks with the magnifier glyph in the input's icon color.

Custom icon
import { SearchField } from '@systhemaui/next'

export default function Demo() {
  return (
    <div style={{ width: '100%', maxWidth: 480 }}>
      <SearchField
        name="q-location"
        placeholder="City or postcode"
        aria-label="City or postcode"
        icon={
          <svg
            viewBox="0 -960 960 960"
            width="100%"
            height="100%"
            fill="currentColor"
            focusable="false"
          >
            <path d="M480-480q33 0 56.5-23.5T560-560q0-33-23.5-56.5T480-640q-33 0-56.5 23.5T400-560q0 33 23.5 56.5T480-480Zm0 400Q319-217 239.5-334.5T160-552q0-150 96.5-239T480-880q127 0 223.5 89T800-552q0 100-79.5 217.5T480-80Z" />
          </svg>
        }
      />
    </div>
  )
}

The icon sits in an aria-hidden span, so it is never announced.

PropsLink to this section

className goes on the wrapper and inputClassName on the input; type defaults to 'search'.

PropTypeDefaultDescription
classNamestring-Class applied to the field wrapper (the positioning context).
inputClassNamestring-Class applied to the inner <input>.
iconReactNode-Custom trailing icon. When omitted the wrapper renders an empty .search-field-icon span, which the core CSS masks with the default magnifier glyph (mirroring how .form-select-icon masks the chevron).
…and all <input> attributes

HTML and CSSLink to this section

<form role="search" action="/search">
  <div class="search-field">
    <input class="search-field-input" type="search" name="q" aria-label="Search" />
    <span class="search-field-icon" aria-hidden="true"></span>
  </div>
</form>

The icon must follow the input as a sibling (.search-field-input ~ .search-field-icon). The CSS also hides the browser's own search decoration and clear button, so the magnifier is the only glyph.

ClassStyles
form& > :not([hidden], input[type="hidden"]) ~ :not([hidden], input[type="hidden"]) { margin-top: var(--form-row-gap-y); }
form-row@media (width < 640px) { & { display: flex; flex-direction: column; row-gap: var(--form-row-gap-y); } }@media (width >= 640px) { & { display: flex; flex-wrap: wrap; gap: var(--form-row-gap-x); } }@media (width >= 640px) { & > .form-group { flex: 1 1 0%; min-width: 0; } }@media (width >= 640px) { & > .form-group[style*="--width"] { flex: 0 0 auto; width: calc(var(--width) - var(--form-row-gap-x) * (1 - var(--width) / 100%)); } }
form-group@media (width >= 640px) { .form-row > .form-group { flex: 1 1 0%; min-width: 0; } }@media (width >= 640px) { .form-row > .form-group[style*="--width"] { flex: 0 0 auto; width: calc(var(--width) - var(--form-row-gap-x) * (1 - var(--width) / 100%)); } }& > :not([hidden]) ~ :not([hidden]) { margin-top: var(--form-group-gap-y); }&:has(:required) .form-label::after { content: ' *'; color: var(--color-form-label-required); }+ 1 more rule
form-label& { display: block; color: var(--color-form-label); }& { font-family: var(--font-form-label-font-family); font-size: var(--typography-form-label-font-size); font-weight: var(--font-form-label-font-weight); letter-spacing: var(--typography-form-label-letter-spacing); line-height: var(--typography-form-label-line-height); text-transform: none; text-decoration: none; font-style: var(--font-form-label-font-style); }+ 1 more rule
form-description& { display: block; color: var(--color-form-description); }& { font-family: var(--font-form-description-font-family); font-size: var(--typography-form-description-font-size); font-weight: var(--font-form-description-font-weight); letter-spacing: var(--typography-form-description-letter-spacing); line-height: var(--typography-form-description-line-height); text-transform: none; text-decoration: none; font-style: var(--font-form-description-font-style); }
form-input& { --tw-shadow-color: var(--color-form-input-block-default-shadow); --tw-shadow: var(--tw-shadow-color); width: 100%; outline: none; border-style: solid; padding-inline: var(--form-input-block-padding-x); padding-block: var(--form-input-block-padding-y); border-radius: var(--form-input-block-border-radius); border-width: var(--form-input-block-border-width); background-color: var(--color-form-input-block-default-background); border-color: var(--color-form-input-block-defaul…+ 4 more rules
form-select& { --tw-shadow-color: var(--color-form-input-block-default-shadow); --tw-shadow: var(--tw-shadow-color); width: 100%; outline: none; border-style: solid; padding-inline: var(--form-input-block-padding-x); padding-block: var(--form-input-block-padding-y); border-radius: var(--form-input-block-border-radius); border-width: var(--form-input-block-border-width); background-color: var(--color-form-input-block-default-background); border-color: var(--color-form-input-block-defaul…+ 12 more rules
form-textarea& { --tw-shadow-color: var(--color-form-input-block-default-shadow); --tw-shadow: var(--tw-shadow-color); width: 100%; outline: none; border-style: solid; padding-inline: var(--form-input-block-padding-x); padding-block: var(--form-input-block-padding-y); border-radius: var(--form-input-block-border-radius); border-width: var(--form-input-block-border-width); background-color: var(--color-form-input-block-default-background); border-color: var(--color-form-input-block-defaul…+ 5 more rules
search-field-input& { --tw-shadow-color: var(--color-form-input-block-default-shadow); --tw-shadow: var(--tw-shadow-color); width: 100%; outline: none; border-style: solid; padding-inline: var(--form-input-block-padding-x); padding-block: var(--form-input-block-padding-y); border-radius: var(--form-input-block-border-radius); border-width: var(--form-input-block-border-width); background-color: var(--color-form-input-block-default-background); border-color: var(--color-form-input-block-defaul…+ 9 more rules
is-invalid:is(.form-select:user-invalid ~ .form-select-icon, .form-select.is-invalid ~ .form-select-icon) { color: var(--color-form-input-block-invalid-icon); background-color: var(--color-form-input-block-invalid-icon); }:is(.form-group:has(:user-invalid) .form-invalid-message, .form-group:has(.is-invalid) .form-invalid-message) { display: block; }
form-select-wrapperposition: relative;
form-select-icon:is(.form-select ~ .form-select-icon) { pointer-events: none; position: absolute; top: 50%; transform: translateY(-50%); object-fit: contain; object-position: center; overflow: visible; line-height: 100%; right: var(--form-input-block-padding-x); width: var(--form-input-block-icon-size); height: var(--form-input-block-icon-size); font-size: var(--form-input-block-icon-size); color: var(--color-form-input-block-default-icon); }+ 3 more rules
search-fieldposition: relative;
search-field-icon:is(.search-field-input ~ .search-field-icon) { pointer-events: none; position: absolute; top: 50%; transform: translateY(-50%); object-fit: contain; object-position: center; overflow: visible; line-height: 100%; right: var(--form-input-block-padding-x); width: var(--form-input-block-icon-size); height: var(--form-input-block-icon-size); font-size: var(--form-input-block-icon-size); color: var(--color-form-input-block-default-icon); }+ 2 more rules
form-inline-input-group& > :not([hidden]) ~ :not([hidden]) { margin-top: var(--form-input-inline-space-y); }
form-radio& { position: relative; user-select: none; }& input[type="radio"], .form-radio input[type="checkbox"], .form-radio:before { content: ""; position: absolute; left: 0; top: calc(var(--typography-form-inline-line-height) / 2); transform: translateY(-50%); width: var(--form-input-inline-input-size); height: var(--form-input-inline-input-size); }& input[type="radio"], .form-radio input[type="checkbox"] { opacity: 0; cursor: pointer; }&:has(:checked):after { opacity: 1; }+ 13 more rules
form-checkbox& { position: relative; user-select: none; }& input[type="radio"], .form-checkbox input[type="checkbox"], .form-checkbox:before { content: ""; position: absolute; left: 0; top: calc(var(--typography-form-inline-line-height) / 2); transform: translateY(-50%); width: var(--form-input-inline-input-size); height: var(--form-input-inline-input-size); }& input[type="radio"], .form-checkbox input[type="checkbox"] { opacity: 0; cursor: pointer; }+ 14 more rules
form-invalid-message& { display: none; color: var(--color-form-invalid-message); }:is(.form-group:has(:user-invalid) .form-invalid-message, .form-group:has(.is-invalid) .form-invalid-message) { display: block; }
form-file-input& { cursor: pointer; padding: 0; height: calc(var(--form-input-block-padding-y) * 2 + var(--typography-form-block-line-height)); display: flex; align-items: center; }+ 2 more rules

Next.jsLink to this section

@systhemaui/next re-exports SearchField from @systhemaui/react unchanged. The Posts module's ArchiveSearchFilter is built from SearchField and FormSelect.

AccessibilityLink to this section

  • SearchField renders no label. Give the input an accessible name with aria-label, or a FormLabel whose htmlFor matches its id; a placeholder is not a label.
  • Wrap it in <form role="search"> (or a <search> element) so assistive technology lists it as a search landmark, and so Enter submits.