Inputs
Search field
A search input with a clear button, a shortcut hint, a loading state and a politely announced result count.
- Built on
<input type="search">role="search"role="status"@starting-style- Client JavaScript
- Yes, for state
- Works without React
- React only (needs JavaScript)
- Source, gzipped
- 3.0 kB
- Dependencies
- React
Try its options
Loading its options.
Code
import { SearchField } from "@/components/equip/search-field";
export default function Demo() {
return (
<div style={{ width: 210 }}>
<SearchField label="Search projects" placeholder="Search projects" shortcut="/" />
</div>
);
}Your versionchange its options in Look, This part
Examples
Debounced, with loading and a result count
The count is announced politely once the search settles, and stays quiet while it loads.
eu-west-1 eu-west-2 eu-central-1 us-east-1 us-east-2 us-west-2 ap-south-1 ap-northeast-1
Code
"use client";
import { useEffect, useState } from "react";
import { SearchField } from "@/components/equip/search-field";
const regions = ["eu-west-1", "eu-west-2", "eu-central-1", "us-east-1", "us-east-2", "us-west-2", "ap-south-1", "ap-northeast-1"];
export default function Example() {
const [query, setQuery] = useState("");
const [shown, setShown] = useState(regions);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!loading) return;
const id = setTimeout(() => {
setShown(regions.filter((r) => r.includes(query.trim().toLowerCase())));
setLoading(false);
}, 400);
return () => clearTimeout(id);
}, [query, loading]);
return (
<div style={{ display: "grid", gap: 8, width: "100%", maxWidth: 260 }}>
<SearchField
label="Filter regions"
placeholder="Filter regions"
debounce={200}
onValueChange={(next) => {
setQuery(next);
setLoading(true);
}}
loading={loading}
resultCount={shown.length}
/>
<p style={{ margin: 0, fontSize: 13, fontFamily: "ui-monospace, monospace" }}>{shown.join(" ") || "No regions match"}</p>
</div>
);
}Sizes and a visible label
Code
import { SearchField } from "@/components/equip/search-field";
export default function Example() {
return (
<div style={{ display: "grid", gap: 12, width: "100%", maxWidth: 260 }}>
<SearchField label="Search logs" placeholder="Search logs" size="s" />
<SearchField label="Search issues" placeholder="Search issues" defaultValue="flaky checkout test" />
<SearchField label="Search the docs" hideLabel={false} placeholder="Try “rate limits”" size="l" />
</div>
);
}Search on Enter
Escape clears the text first and only blurs the field on a second press.
Press Enter to search
Code
"use client";
import { useState } from "react";
import { SearchField } from "@/components/equip/search-field";
export default function Example() {
const [last, setLast] = useState<string | null>(null);
return (
<div style={{ display: "grid", gap: 8, width: "100%", maxWidth: 260 }}>
<SearchField label="Search invoices" placeholder="Invoice number or customer" onSearch={setLast} name="q" />
<p style={{ margin: 0, fontSize: 13 }}>{last === null ? "Press Enter to search" : `Searched for "${last}"`}</p>
</div>
);
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
label | ReactNode | none | Accessible label, visually hidden unless hideLabel is false. |
hideLabel | boolean | true | Keeps the label for assistive tech only. |
value | string | none | Controlled text. Pair it with onValueChange. |
defaultValue | string | "" | Uncontrolled starting text. |
onValueChange | (value: string) => void | none | Called as the text changes, after debounce milliseconds of quiet. |
onSearch | (value: string) => void | none | Called on Enter with the current text. |
debounce | number | 0 | Milliseconds to wait before calling onValueChange. |
loading | boolean | false | Swaps the search icon for a spinner and sets aria-busy. |
resultCount | number | none | Announced politely, as 12 results, once the search settles. |
shortcut | string | none | Key shown while the field is empty and unfocused. Display only. |
size | "s" | "m" | "l" | "m" | Control height. |
...rest | ComponentProps<"input"> | none | Every native input attribute, including name, placeholder, disabled and ref. className and style go to the form. |
Anatomy
| root | .eq-search-field | A form with role="search". |
|---|---|---|
| label | .eq-search-field-label | Visually hidden by default. |
| control | .eq-search-field-control | The bordered box around the icon, input and buttons. |
| icon | .eq-search-field-icon | |
| spinner | .eq-search-field-spinner | Replaces the icon while loading. |
| input | .eq-search-field-input | |
| shortcut | .eq-search-field-shortcut | Shown only while empty and unfocused. |
| clear | .eq-search-field-clear | Appears once there is text. |
| status | .eq-search-field-status | Visually hidden polite live region for the result count. |
Keyboard
| Enter | Runs onSearch, flushing any pending debounced change first. |
|---|---|
| Escape | Clears the text; a second press, on an empty field, leaves the field. |
| Tab | Moves to the clear button when there is text. |
Notes
- The shortcut is only a hint: focusing the field when it is pressed is the app's job.
- The browser's own clear button is hidden so there is one consistent clear button, which returns focus to the input.
- Escape stops propagating only on the press that clears, so a second press can still close a surrounding dialog.
- The live region stays mounted and silent while loading, then speaks the count once, so it never chatters during typing.
- The root is a form, so do not place it inside another form; Enter is handled and never reloads the page.
- The field keeps its own text while debouncing, so a slow parent never makes typing lag.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/search-field.tsx175 lines
"use client";
import { useEffect, useId, useRef, useState } from "react";
import type { ChangeEvent, ComponentProps, FormEvent, KeyboardEvent, ReactNode } from "react";
import "./search-field.css";
export type SearchFieldProps = Omit<
ComponentProps<"input">,
"type" | "value" | "defaultValue" | "onChange" | "size" | "children"
> & {
label: ReactNode;
/** Keeps the label for assistive tech only. */
hideLabel?: boolean;
value?: string;
defaultValue?: string;
/** Called as the text changes, after `debounce` milliseconds of quiet. */
onValueChange?: (value: string) => void;
/** Called on Enter, with the current text. */
onSearch?: (value: string) => void;
debounce?: number;
loading?: boolean;
/** Announced politely once a search settles, e.g. "12 results". */
resultCount?: number;
/** Key shown as a hint while the field is empty and unfocused. Display only. */
shortcut?: string;
size?: "s" | "m" | "l";
};
export function SearchField({
label,
hideLabel = true,
value,
defaultValue = "",
onValueChange,
onSearch,
debounce = 0,
loading = false,
resultCount,
shortcut,
size = "m",
disabled,
className,
style,
id,
onKeyDown,
...rest
}: SearchFieldProps) {
const autoId = useId();
const inputId = id ?? autoId;
const inputRef = useRef<HTMLInputElement>(null);
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const pending = useRef<string | null>(null);
const emitted = useRef(value ?? defaultValue);
// The field keeps its own text even when controlled, so a debounced parent never makes typing lag.
const [text, setText] = useState(value ?? defaultValue);
useEffect(() => {
if (value !== undefined && value !== emitted.current) {
clearTimeout(timer.current);
pending.current = null;
emitted.current = value;
setText(value);
}
}, [value]);
useEffect(() => () => clearTimeout(timer.current), []);
function emit(next: string) {
clearTimeout(timer.current);
pending.current = null;
emitted.current = next;
onValueChange?.(next);
}
function handleChange(event: ChangeEvent<HTMLInputElement>) {
const next = event.currentTarget.value;
setText(next);
if (debounce <= 0) return emit(next);
clearTimeout(timer.current);
pending.current = next;
timer.current = setTimeout(() => emit(next), debounce);
}
function clear() {
setText("");
emit("");
}
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (pending.current !== null) emit(pending.current);
onSearch?.(text);
}
function handleKeyDown(event: KeyboardEvent<HTMLInputElement>) {
onKeyDown?.(event);
if (event.defaultPrevented || event.key !== "Escape") return;
event.preventDefault();
if (text) {
// Only the press that clears is kept from the page; the next one is free to close a dialog.
event.stopPropagation();
clear();
} else {
event.currentTarget.blur();
}
}
const status =
loading || resultCount === undefined || text === ""
? ""
: `${resultCount} ${resultCount === 1 ? "result" : "results"}`;
return (
<form
role="search"
className={["eq-search-field", className].filter(Boolean).join(" ")}
style={style}
data-size={size}
data-empty={text === "" || undefined}
data-disabled={disabled || undefined}
aria-busy={loading || undefined}
onSubmit={handleSubmit}
>
<label className="eq-search-field-label" htmlFor={inputId} data-hidden={hideLabel || undefined}>
{label}
</label>
<div className="eq-search-field-control">
{loading ? (
<span className="eq-search-field-spinner" aria-hidden="true" />
) : (
<svg className="eq-search-field-icon" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<circle cx="7" cy="7" r="4.25" stroke="currentColor" strokeWidth="1.5" />
<path d="m10.25 10.25 3 3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
)}
<input
autoComplete="off"
enterKeyHint="search"
{...rest}
ref={inputRef}
id={inputId}
className="eq-search-field-input"
type="search"
value={text}
disabled={disabled}
onChange={handleChange}
onKeyDown={handleKeyDown}
/>
{shortcut && (
<kbd className="eq-search-field-shortcut" aria-hidden="true">
{shortcut}
</kbd>
)}
{text !== "" && !disabled && (
<button
type="button"
className="eq-search-field-clear"
aria-label="Clear search"
onClick={() => {
clear();
inputRef.current?.focus();
}}
>
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="m5 5 6 6m0-6-6 6" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
</button>
)}
</div>
<span className="eq-search-field-status" role="status">
{status}
</span>
</form>
);
}components/equip/search-field.css194 lines
@layer equip {
.eq-search-field {
--_h: var(--eq-h-m);
--_text: var(--eq-text-m);
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--eq-space-1);
min-width: 0;
margin: 0;
}
.eq-search-field[data-size="s"] {
--_h: var(--eq-h-s);
--_text: var(--eq-text-s);
}
.eq-search-field[data-size="l"] {
--_h: var(--eq-h-l);
--_text: var(--eq-text-l);
}
.eq-search-field-label {
color: var(--eq-ink);
font: var(--eq-weight-strong) var(--eq-text-s) / 1.3 var(--eq-font);
}
.eq-search-field-label[data-hidden],
.eq-search-field-status {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.eq-search-field-control {
position: relative;
display: flex;
align-items: center;
gap: var(--eq-space-2);
min-width: 0;
height: var(--_h);
padding-inline: var(--eq-space-3) var(--eq-space-2);
border: 1px solid var(--eq-line-strong);
border-radius: var(--eq-radius-m);
background: var(--eq-raised);
color: var(--eq-ink);
font-size: var(--_text);
transition: border-color var(--eq-dur) var(--eq-ease);
}
.eq-search-field-control:has(.eq-search-field-input:focus-visible) {
outline: 2px solid var(--eq-focus);
outline-offset: -1px;
}
.eq-search-field-icon,
.eq-search-field-spinner {
flex: none;
width: 1.15em;
height: 1.15em;
color: var(--eq-ink-3);
}
.eq-search-field-spinner {
box-sizing: border-box;
width: 1em;
height: 1em;
margin-inline: 0.075em;
border: 2px solid color-mix(in srgb, currentColor 30%, transparent);
border-top-color: currentColor;
border-radius: var(--eq-radius-full);
color: var(--eq-ink-2);
animation: eq-search-field-spin 700ms linear infinite;
}
@keyframes eq-search-field-spin {
to {
rotate: 1turn;
}
}
@media (prefers-reduced-motion: reduce) {
.eq-search-field-spinner {
animation-duration: 2.4s;
}
}
.eq-search-field-input {
flex: 1;
min-width: 0;
height: 100%;
padding: 0;
border: 0;
background: transparent;
color: inherit;
font: var(--eq-weight) var(--_text) / 1 var(--eq-font);
appearance: none;
}
.eq-search-field-input:focus-visible {
outline: none;
}
.eq-search-field-input::placeholder {
color: var(--eq-ink-3);
}
/* The part draws its own clear button. */
.eq-search-field-input::-webkit-search-cancel-button,
.eq-search-field-input::-webkit-search-decoration {
appearance: none;
display: none;
}
.eq-search-field-shortcut {
display: none;
flex: none;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
margin-inline-end: var(--eq-space-1);
padding-inline: var(--eq-space-1);
box-sizing: border-box;
border: 1px solid var(--eq-line-strong);
border-radius: var(--eq-radius-s);
color: var(--eq-ink-2);
font: var(--eq-weight-strong) var(--eq-text-xs) / 1 var(--eq-font-mono);
font-variation-settings: var(--eq-font-mono-settings);
}
.eq-search-field[data-empty]:not(:focus-within) .eq-search-field-shortcut {
display: inline-flex;
}
.eq-search-field-clear {
display: grid;
place-items: center;
flex: none;
width: 24px;
height: 24px;
padding: 0;
border: 0;
border-radius: var(--eq-radius-s);
background: transparent;
color: var(--eq-ink-2);
cursor: pointer;
transition:
background-color var(--eq-dur) var(--eq-ease),
color var(--eq-dur) var(--eq-ease),
opacity var(--eq-dur) var(--eq-ease),
scale var(--eq-dur-move) var(--eq-spring);
}
@starting-style {
.eq-search-field-clear {
opacity: 0;
scale: 0.6;
}
}
.eq-search-field-clear > svg {
width: 16px;
height: 16px;
}
@media (hover: hover) {
.eq-search-field-clear:hover {
background: var(--eq-tint);
color: var(--eq-ink);
}
}
.eq-search-field-clear:active {
scale: var(--eq-press);
transition: scale var(--eq-dur) var(--eq-ease);
}
.eq-search-field-clear:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
.eq-search-field[data-disabled] {
opacity: 0.45;
}
.eq-search-field[data-disabled] .eq-search-field-input {
cursor: not-allowed;
}
}