A search box with a clear button, Escape to clear and Enter to submit.
Basic
Pass a label, and a hint if the label cannot say it all. The placeholder shows what the field searches and never replaces the label. A clear button shows at the end only while there is text. Escape also clears, and Enter submits the text to onSubmit.
Example
Example
import { SearchField } from '@snapshot-ui/react';
import type { ReactElement } from 'react';
export default function SearchFieldBasic(): ReactElement {
return (
<SearchField
label="Search transactions"
description="Escape clears the text."
placeholder="Merchant or note"
style={{ width: '100%', maxWidth: '24rem' }}
/>
);
}In a toolbar
Above a list there is often no room for a label row. Name the field with aria-label, and use a placeholder that says what it searches. With no visible label, the placeholder is the only text a sighted reader gets, so it is slate instead of taupe. The command palette's field makes the same exception.
Example
Example
import { Button, SearchField } from '@snapshot-ui/react';
import type { ReactElement } from 'react';
export default function SearchFieldToolbar(): ReactElement {
return (
<div className="row" style={{ width: '100%', maxWidth: '32rem', flexWrap: 'nowrap' }}>
<SearchField
aria-label="Search payees"
placeholder="Search payees"
style={{ flex: '1 1 auto', minWidth: 0 }}
/>
<Button variant="secondary">Export</Button>
</div>
);
}States
A disabled field is recessed, with a taupe magnifier. An invalid field shows the error edge and error line. There is no changed state, since a query is not saved data.
Example
Example
import { SearchField } from '@snapshot-ui/react';
import type { ReactElement } from 'react';
export default function SearchFieldStates(): ReactElement {
return (
<div className="stack" style={{ width: '100%', maxWidth: '24rem' }}>
<SearchField label="Search archived" defaultValue="coffee" isDisabled />
<SearchField
label="Search notes"
defaultValue="c"
isInvalid
errorMessage="Search needs two letters or more."
/>
</div>
);
}Usage
Keep the query in the URL, so a search can be shared, reloaded and revisited. Keep the typed text in state, pass it as value, and write the URL after your own pause in onChange.
- When the URL changes under the field, as with Back and Forward, take its query again.
- When the pause ends, read the URL as it is then, so a tab or page chosen meanwhile is kept.
With the Next App Router:
'use client';
import { useEffect, useRef, useState } from 'react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { SearchField } from '@snapshot-ui/react';
export function TransactionSearch() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const q = searchParams.get('q') ?? '';
const [text, setText] = useState(q);
// The query the field last took from the URL, or last wrote to it.
const [seen, setSeen] = useState(q);
const pause = useRef<ReturnType<typeof setTimeout>>(undefined);
// The URL changed under the field, by Back, Forward or a link: show its query.
if (q !== seen) {
setSeen(q);
setText(q);
}
useEffect(() => () => clearTimeout(pause.current), []);
return (
<SearchField
aria-label="Search transactions"
placeholder="Search transactions"
value={text}
onChange={(next) => {
setText(next);
clearTimeout(pause.current);
pause.current = setTimeout(() => {
// The parameters as they are now, not as they were at the keystroke.
const params = new URLSearchParams(window.location.search);
if (next) params.set('q', next);
else params.delete('q');
const query = params.toString();
setSeen(next);
router.replace(query ? `${pathname}?${query}` : pathname);
}, 300);
}}
/>
);
}On a statically rendered route, render the search inside <Suspense>, or the build fails asking for one around useSearchParams.
To search only on Enter, such as against a slow service, write the URL in onSubmit and onClear, and let onChange keep only the text. Start from the existing parameters, so the tab, page and filters stay:
export function TransactionSearchOnEnter() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const q = searchParams.get('q') ?? '';
const [text, setText] = useState(q);
const [seen, setSeen] = useState(q);
if (q !== seen) {
setSeen(q);
setText(q);
}
const search = (next: string) => {
const params = new URLSearchParams(searchParams);
if (next) params.set('q', next);
else params.delete('q');
const query = params.toString();
router.push(query ? `${pathname}?${query}` : pathname);
};
return (
<SearchField
aria-label="Search transactions"
placeholder="Search transactions"
value={text}
onChange={setText}
onSubmit={search}
onClear={() => search('')}
/>
);
}The field has no debounce of its own. The app's router and data fetching know what a request costs, so they decide how long to wait. A pause inside the field would be a second timer the app cannot see.
Props
Every React Aria SearchField prop is accepted. These are the ones Snapshot UI adds or shapes.
aria-label string | Defines a string value that labels the current element. |
|---|---|
className string | ((values: SearchFieldRenderProps & { defaultClassName: string | undefined; }) => string) | A class name, or a function of the field's state, merged after the library's own classes. |
clearLabel string | The clear button's name. Default: 'Clear search' |
defaultValue string | The default value (uncontrolled). |
description ReactNode | A hint under the field. It becomes the input's accessible description. |
errorMessage ((validation: ValidationResult) => ReactNode) | ReactNode | The error line, shown only while the field is invalid. A function receives the validation result. Without it the field shows its own validation messages. |
isDisabled boolean | Whether the input is disabled. |
isInvalid boolean | Whether the input value is invalid. |
label ReactNode | The field's name, shown above it. Required unless the field has an |
name string | The name of the input element, used when submitting an HTML form. See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefname). |
onChange ((value: string) => void) | Handler that is called when the value changes. |
onClear (() => void) | Handler that is called when the clear button is pressed. |
onSubmit ((value: string) => void) | Handler that is called when the SearchField is submitted. |
placeholder string | Example text while the field is empty. Taupe under a visible label, slate without one. |
value string | The current value (controlled). |
Accessibility
A native input of type search (a searchbox), named by its label or aria-label and described by the hint and error line.
- Clear button: named "Clear search", or
clearLabel. It is out of the tab order, since Escape does the same. A press clears the text and keeps focus in the input. - Magnifier: hidden from assistive technology.
| Escape | Clears the text; with nothing to clear, it goes on to whatever holds the field. |
|---|---|
| Enter | Submits the text. |
Design rules
What keeps search fields in the system.
Give a search field on a form a visible label.
In a toolbar, name it with aria-label and let the placeholder say what it searches.
Keep the query in the URL, and write it after your own pause or on Enter.
Never use the placeholder as the only label on a form.
Never put a debounce inside the field. The pause belongs to the app.
Never add a second clear button. The field draws one and hides the one the browser draws.