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.
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.
- 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.
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.
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'.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | - | Class applied to the field wrapper (the positioning context). |
inputClassName | string | - | Class applied to the inner <input>. |
icon | ReactNode | - | 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.
| Class | Styles |
|---|---|
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-wrapper | position: 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-field | position: 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
SearchFieldrenders no label. Give the input an accessible name witharia-label, or aFormLabelwhosehtmlFormatches itsid; 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 soEntersubmits.