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

PropTypeDefaultWhat it does
labelReactNodenoneAccessible label, visually hidden unless hideLabel is false.
hideLabelbooleantrueKeeps the label for assistive tech only.
valuestringnoneControlled text. Pair it with onValueChange.
defaultValuestring""Uncontrolled starting text.
onValueChange(value: string) => voidnoneCalled as the text changes, after debounce milliseconds of quiet.
onSearch(value: string) => voidnoneCalled on Enter with the current text.
debouncenumber0Milliseconds to wait before calling onValueChange.
loadingbooleanfalseSwaps the search icon for a spinner and sets aria-busy.
resultCountnumbernoneAnnounced politely, as 12 results, once the search settles.
shortcutstringnoneKey shown while the field is empty and unfocused. Display only.
size"s" | "m" | "l""m"Control height.
...restComponentProps<"input">noneEvery native input attribute, including name, placeholder, disabled and ref. className and style go to the form.

Anatomy

root.eq-search-fieldA form with role="search".
label.eq-search-field-labelVisually hidden by default.
control.eq-search-field-controlThe bordered box around the icon, input and buttons.
icon.eq-search-field-icon
spinner.eq-search-field-spinnerReplaces the icon while loading.
input.eq-search-field-input
shortcut.eq-search-field-shortcutShown only while empty and unfocused.
clear.eq-search-field-clearAppears once there is text.
status.eq-search-field-statusVisually hidden polite live region for the result count.

Keyboard

EnterRuns onSearch, flushing any pending debounced change first.
EscapeClears the text; a second press, on an empty field, leaves the field.
TabMoves 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;
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index