Skip to content
UI Kit
Accent color

Search field

The search box with a shortcut, clear button, Escape to clear, loading and a submit button.

One of 9 components in Forms and inputs

2 variants

Variants

Search field

Ctrl K or ⌘K focuses it from anywhere and the key caps press down. Focus widens the box a hair, a typed query gets a clear button, a running search a spinner.

In a header

The soft style without a border sits quietly in toolbars and turns white on focus.

Details

What Search field brings along, read from the registry and the source.

Variants
2
Version
1.0.0 · since 1.0.0
File
components/ui/search-field.tsx
Brings along
utilskiticoninputloading-button
npm packages
None
Languages
English and German, 5 texts of its own
Studio
Adjust in code

Props

The SearchFieldProps interface from components/ui/search-field.tsx, with the comments from the code. Also takes every prop of Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size' | 'onChange' | 'value' | 'defaultValue'>.

  • valueoptional
    string
  • defaultValueoptional
    string
  • onValueChangeoptional
    (value: string) => void
  • onSearchoptional
    (value: string) => void

    Called on Enter and on the submit button

  • sizeoptional
    ControlSize
  • shortcutoptional
    string

    A key that focuses the field from anywhere, shown as a hint: "k" means Ctrl K or ⌘K, "/" means slash

  • loadingoptional
    boolean

    Shows a spinner instead of the search icon

  • submitLabeloptional
    string

    A submit button at the end

  • variantoptional
    'default' | 'soft'

    soft: filled surface without border, for headers and toolbars

  • labeloptional
    string

    Accessible name, when there is no visible label

Built-in texts

Search field writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 5 texts
labelEnglish SearchGerman Suche
placeholderEnglish SearchGerman Suchen
clearEnglish Clear searchGerman Suche leeren
submitEnglish SearchGerman Suchen
searchingEnglish SearchingGerman Sucht

Accessibility

Read from the source of the component.

  • Search field handles the keys Escape and Space itself.
  • Sets aria-label, so screen readers announce purpose and state.
  • Uses the role search.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • With reduced motion (prefers-reduced-motion) it stays still. 3 places in the code switch motion off then.
  • No animations, it stands still from the start.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 165 lines
components/ui/search-field.tsx
'use client';

import { useEffect, useRef, useState } from 'react';
import { cn, prefersReducedMotion, useTexts } from '@/lib/kit';
import { Icon } from '@/components/ui/icon';
import { CONTROL_HEIGHT, InlineButton, controlBox, type ControlSize } from '@/components/ui/input';
import { Spinner } from '@/components/ui/loading-button';

const TEXTS = {
  en: { label: 'Search', placeholder: 'Search', clear: 'Clear search', submit: 'Search', searching: 'Searching' },
  de: { label: 'Suche', placeholder: 'Suchen', clear: 'Suche leeren', submit: 'Suchen', searching: 'Sucht' },
};

export interface SearchFieldProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size' | 'onChange' | 'value' | 'defaultValue'> {
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  /** Called on Enter and on the submit button */
  onSearch?: (value: string) => void;
  size?: ControlSize;
  /** A key that focuses the field from anywhere, shown as a hint: "k" means Ctrl K or ⌘K, "/" means slash */
  shortcut?: string;
  /** Shows a spinner instead of the search icon */
  loading?: boolean;
  /** A submit button at the end */
  submitLabel?: string;
  /** soft: filled surface without border, for headers and toolbars */
  variant?: 'default' | 'soft';
  /** Accessible name, when there is no visible label */
  label?: string;
  labels?: Partial<(typeof TEXTS)['en']>;
  className?: string;
}

function Kbd({ children }: { children: React.ReactNode }) {
  return <kbd data-cap className="grid h-[22px] min-w-[22px] place-items-center rounded-[5px] border border-border bg-surface px-1.5 font-sans text-[11px] font-medium text-muted-foreground shadow-[0_1px_0_var(--border)]">{children}</kbd>;
}

/** The key caps press down one after the other, as if the shortcut was typed on them */
function pressCaps(root: HTMLElement | null) {
  if (!root || prefersReducedMotion()) return;
  root.querySelectorAll<HTMLElement>('[data-cap]').forEach((cap, i) => {
    if (typeof cap.animate !== 'function') return;
    cap.animate(
      [
        { transform: 'translateY(0)', boxShadow: '0 1px 0 var(--border)' },
        { transform: 'translateY(1.5px) scale(0.94)', boxShadow: '0 0 0 var(--border)', offset: 0.35 },
        { transform: 'translateY(0)', boxShadow: '0 1px 0 var(--border)' },
      ],
      { duration: 260, delay: i * 60, easing: 'cubic-bezier(0.2,0.7,0.2,1)' },
    );
  });
}

/**
 * The search box: icon, clear button, Escape to clear, a keyboard shortcut and a loading state.
 *   <SearchField shortcut="k" placeholder="Search invoices" onSearch={run} />
 */
export function SearchField({ value: valueProp, defaultValue, onValueChange, onSearch, size = 'md', shortcut, loading, submitLabel, variant = 'default', label, labels, className, placeholder, ...rest }: SearchFieldProps) {
  const t = useTexts(TEXTS, labels);
  const [inner, setInner] = useState(defaultValue ?? '');
  const value = valueProp ?? inner;
  const ref = useRef<HTMLInputElement>(null);
  const hintRef = useRef<HTMLSpanElement>(null);
  const [mac, setMac] = useState(true);

  useEffect(() => {
    setMac(/Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent));
    if (!shortcut) return;
    const onKey = (e: KeyboardEvent) => {
      const target = e.target as HTMLElement;
      const typing = target.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName);
      if (shortcut === '/' ? e.key === '/' && !typing : e.key.toLowerCase() === shortcut.toLowerCase() && (e.metaKey || e.ctrlKey)) {
        e.preventDefault();
        pressCaps(hintRef.current);
        ref.current?.focus();
        ref.current?.select();
      }
    };
    document.addEventListener('keydown', onKey);
    return () => document.removeEventListener('keydown', onKey);
  }, [shortcut]);

  const set = (v: string) => {
    setInner(v);
    onValueChange?.(v);
  };

  return (
    <form
      role="search"
      onSubmit={(e) => {
        e.preventDefault();
        onSearch?.(value);
      }}
      className={cn(
        // Focus widens the box a hair and lights the icon, the ring fades in from the shared control box
        'group/search relative flex w-full min-w-0 items-center focus-within:scale-[1.012] motion-reduce:focus-within:scale-100',
        CONTROL_HEIGHT[size],
        controlBox,
        variant === 'soft' && 'border-transparent bg-muted shadow-none hover:border-transparent hover:bg-[color-mix(in_oklab,var(--foreground)_6%,var(--muted))] focus-within:bg-background',
        submitLabel && 'pe-1',
        className,
      )}
    >
      <span className={cn('grid shrink-0 place-items-center text-subtle-foreground transition-[color,scale] duration-300 ease-[var(--ease-kit)] group-focus-within/search:scale-110 group-focus-within/search:text-[color-mix(in_oklab,var(--primary)_70%,var(--foreground))]', size === 'sm' ? 'ms-3' : 'ms-3.5')}>
        {loading ? <Spinner size={16} label={t.searching} /> : <Icon name="search" size={size === 'lg' ? 18 : 16} />}
      </span>
      <input
        {...rest}
        ref={ref}
        type="search"
        value={value}
        placeholder={placeholder ?? t.placeholder}
        aria-label={label ?? t.label}
        enterKeyHint="search"
        onChange={(e) => set(e.target.value)}
        onKeyDown={(e) => {
          if (e.key === 'Escape' && value) {
            e.preventDefault();
            set('');
          }
          rest.onKeyDown?.(e);
        }}
        className="h-full w-0 min-w-0 flex-1 bg-transparent ps-2.5 pe-2 text-inherit outline-none placeholder:text-subtle-foreground [&::-webkit-search-cancel-button]:appearance-none"
      />
      {value ? (
        <span className="pe-1.5 transition-[opacity,scale] duration-200 starting:scale-50 starting:opacity-0 motion-reduce:transition-none">
          <InlineButton
            label={t.clear}
            icon="x"
            onClick={() => {
              set('');
              ref.current?.focus();
            }}
          />
        </span>
      ) : (
        shortcut && (
          <span ref={hintRef} aria-hidden className="flex shrink-0 items-center gap-1 pe-2.5 transition-[opacity,translate] duration-300 ease-[var(--ease-kit)] group-focus-within/search:translate-x-1 group-focus-within/search:opacity-40 max-sm:hidden">
            {shortcut === '/' ? (
              <Kbd>/</Kbd>
            ) : (
              <>
                <Kbd>{mac ? '⌘' : 'Ctrl'}</Kbd>
                <Kbd>{shortcut.toUpperCase()}</Kbd>
              </>
            )}
          </span>
        )
      )}
      {submitLabel && (
        <button
          type="submit"
          className={cn(
            'inline-flex shrink-0 items-center rounded-md bg-primary px-3.5 text-[13px] font-medium text-primary-foreground shadow-[inset_0_1px_0_rgb(255_255_255/0.18)] transition-[background-color,scale] duration-200 hover:bg-[color-mix(in_oklab,var(--primary)_88%,var(--background))] active:scale-[0.96] active:duration-100 motion-reduce:active:scale-100',
            size === 'sm' ? 'h-7' : size === 'md' ? 'h-9' : 'h-10',
          )}
        >
          {submitLabel}
        </button>
      )}
    </form>
  );
}

Frequent questions

How do I install Search field?

With aiw add @ai-website/search-field. The command puts components/ui/search-field.tsx into your project, the code is yours from then on. It brings along icon, input and loading-button. It needs no further npm packages.

Which variants does Search field have?

2 variants, Search field and In a header.

Can I translate the texts of Search field?

Yes. Search field carries 5 texts of its own in English and German, such as “Search”, “Search” and “Clear search”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Search field?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Search field with aiw_components and install it with aiw_add @ai-website/search-field. Name the variant in your prompt, for example variant="soft", and the assistant writes your content straight into the props.

Often searched

Jump to

Copied to the clipboard