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
Details
What Search field brings along, read from the registry and the source.
- Category
- Forms and inputs
- Area
- Elements
- 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
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
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'>.
valueoptionalstringdefaultValueoptionalstringonValueChangeoptional(value: string) => voidonSearchoptional(value: string) => voidCalled on Enter and on the submit button
sizeoptionalControlSizeshortcutoptionalstringA key that focuses the field from anywhere, shown as a hint: "k" means Ctrl K or ⌘K, "/" means slash
loadingoptionalbooleanShows a spinner instead of the search icon
submitLabeloptionalstringA submit button at the end
variantoptional'default' | 'soft'soft: filled surface without border, for headers and toolbars
labeloptionalstringAccessible 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
| Key | English | German |
|---|---|---|
| label | English Search | German Suche |
| placeholder | English Search | German Suchen |
| clear | English Clear search | German Suche leeren |
| submit | English Search | German Suchen |
| searching | English Searching | German 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
'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.