Search query field
A search box in a role=search GET form: magnifier, query, a clear button once there is text, an optional / shortcut hint and an inset submit, all one shape. Submits ?q= without JS; optional debounced live filtering.
cmp_search_query_field_01 Preview
Fit to the available width. The frame follows the height of its content; previews taller than the maximum auto-height scroll inside it.
Give this component to your coding agent Copy a prompt that fetches this exact version and palette through the PageSugar MCP server.
Using the PageSugar MCP server, fetch component cmp_search_query_field_01 version 1.0.0 with variant "blue", first inspect its requirements and license status and confirm this project uses Svelte 5 and Tailwind CSS 4. Retrieve every manifest file, including binary assets and any manifest-only response files, preserving relative paths. Then integrate the source and follow its usage notes. Run project checks, review the browser result and report anything unverified. Do not substitute another version or invent missing files.Not connected yet? Set up the MCP server
Code
- Palette
- Blue accent
- Version
- 1.0.0
- Digest
Full digest
sha256-3b6823ca324e626640f856b502836eec72cae2afaa4d492cc072c717718a91e7
<!--
A search box: a search landmark form holding a label, a leading magnifier, a type=search input,
a clear button once there is a query, an optional shortcut hint and an optional submit button,
all inside one bordered shape. It is a plain GET form, so before any script runs Enter submits
?q= to the action. onSearch intercepts submission; onInput reports typing after a debounce.
-->
<script lang="ts" module>
export type SearchQueryFieldSize = 'sm' | 'md' | 'lg';
export type SearchQueryFieldShape = 'rounded' | 'pill';
</script>
<script lang="ts">
import type { HTMLInputAttributes, KeyboardEventHandler } from 'svelte/elements';
type Passthrough = Omit<
HTMLInputAttributes,
| 'type'
| 'value'
| 'id'
| 'name'
| 'size'
| 'class'
| 'disabled'
| 'children'
| 'oninput'
| 'onkeydown'
| 'form'
>;
interface Props extends Passthrough {
/** Accessible label, also the landmark's name. Visually hidden unless labelVisible. */
label: string;
/** Bindable query. */
value?: string;
/** Show the label above the field instead of only to assistive technology. */
labelVisible?: boolean;
/** Form action for GET submission. Defaults to the current page. */
action?: string;
/** Query parameter name. */
name?: string;
/** Called on submit instead of navigating. */
onSearch?: (query: string) => void;
/** Called with the query once typing pauses for debounceMs, and with '' on clear. */
onInput?: (query: string) => void;
/** Pause before onInput fires, in milliseconds. */
debounceMs?: number;
/** Render a submit button inside the field. */
showSubmit?: boolean;
/** Submit button text; the accessible name when it collapses to an icon. */
submitLabel?: string;
/** Accessible name of the clear button. */
clearLabel?: string;
/** Single key that focuses the field from anywhere on the page, shown as a hint. */
shortcut?: string;
/** 'rounded' for app toolbars and docs; 'pill' for consumer sites. */
shape?: SearchQueryFieldShape;
/** sm 32 px, md 36 px, lg 40 px tall; 46 px on touch screens. */
size?: SearchQueryFieldSize;
/** Disable the field and its buttons. */
disabled?: boolean;
/** Keydown on the input, called before the field's own Escape handling. */
onkeydown?: KeyboardEventHandler<HTMLInputElement>;
/** Input id. Defaults to one generated per instance. */
id?: string;
/** Extra classes on the form, for placement. */
class?: string;
}
let {
label,
value = $bindable(''),
labelVisible = false,
action,
name = 'q',
onSearch,
onInput,
debounceMs = 250,
showSubmit = false,
submitLabel = 'Search',
clearLabel = 'Clear search',
shortcut,
shape = 'rounded',
size = 'md',
disabled = false,
onkeydown,
id,
class: className,
...rest
}: Props = $props();
const uid = $props.id();
const inputId = $derived(id ?? `${uid}-input`);
const labelId = `${uid}-label`;
let input: HTMLInputElement | undefined = $state();
/* The clear button only works with script, so it appears once the page has hydrated. */
let hydrated = $state(false);
let timer: ReturnType<typeof setTimeout> | undefined;
/* A read-only query can be selected and submitted, but not cleared. */
const readonly = $derived(!!rest.readonly);
const clearable = $derived(hydrated && !!value && !readonly && !disabled);
$effect(() => {
hydrated = true;
return () => clearTimeout(timer);
});
function handleInput(event: Event & { currentTarget: HTMLInputElement }) {
if (!onInput) return;
const query = event.currentTarget.value;
clearTimeout(timer);
timer = setTimeout(() => {
timer = undefined;
/* Re-read: the parent may have removed the callback while the timer ran. */
onInput?.(query);
}, debounceMs);
}
function clear() {
clearTimeout(timer);
timer = undefined;
value = '';
onInput?.('');
input?.focus();
}
function handleKeydown(event: KeyboardEvent & { currentTarget: HTMLInputElement }) {
onkeydown?.(event);
if (event.defaultPrevented || event.key !== 'Escape') return;
/* Escape during IME composition dismisses the candidates, not the query. */
if (event.isComposing) return;
/* An empty field lets Escape through, so a dialog or menu around it can still close. */
if (!value || readonly) return;
event.preventDefault();
clear();
}
function handleSubmit(event: SubmitEvent) {
if (!onSearch) return;
event.preventDefault();
/* A pending onInput fires first, so a live filter and the search agree on the query. */
if (timer !== undefined) {
clearTimeout(timer);
timer = undefined;
onInput?.(value);
}
onSearch(value);
}
/* The shortcut focuses the field unless someone is typing elsewhere or holds a modifier. */
function handleShortcut(event: KeyboardEvent) {
if (!shortcut || disabled || !input || event.defaultPrevented) return;
if (event.key !== shortcut || event.metaKey || event.ctrlKey || event.altKey) return;
const target = event.target as HTMLElement | null;
if (
target?.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"])')
)
return;
event.preventDefault();
input.focus();
}
/* A press on the icon or the border focuses the input, as a press inside one box should. */
function focusInput(event: PointerEvent) {
if (!input || disabled || event.button !== 0 || event.target === input) return;
if ((event.target as Element).closest('button')) return;
event.preventDefault();
input.focus();
}
</script>
<svelte:window onkeydown={handleShortcut} />
<form
role="search"
method="get"
{action}
aria-labelledby={labelId}
class={['search-query-field @container grid min-w-0 gap-2', className]}
data-size={size}
data-shape={shape}
onsubmit={handleSubmit}
>
<label
id={labelId}
for={inputId}
class={[
labelVisible
? 'block text-sm leading-5 font-medium text-pretty break-words text-(--_ink)'
: 'sr-only'
]}
>
{label}
</label>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class={[
'search-query-field__control group flex h-(--_h) min-w-0 items-stretch rounded-(--_r) border',
'border-(--_edge) bg-(--_surface) text-(--_ink)'
]}
onpointerdown={focusInput}
>
<span
class={['flex shrink-0 items-center text-(--_muted)', shape === 'pill' ? 'ps-4' : 'ps-3']}
aria-hidden="true"
>
<svg class={size === 'sm' ? 'size-3.5' : 'size-4'} viewBox="0 0 16 16" fill="none">
<circle cx="7" cy="7" r="4.75" stroke="currentColor" stroke-width="1.75" />
<path d="m10.5 10.5 3 3" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" />
</svg>
</span>
<input
bind:this={input}
autocomplete="off"
enterkeyhint="search"
spellcheck={false}
{...rest}
bind:value
type="search"
{name}
{disabled}
id={inputId}
aria-keyshortcuts={shortcut || undefined}
oninput={handleInput}
onkeydown={handleKeydown}
class={[
'search-query-field__input min-w-0 flex-1 bg-transparent ps-2 text-(--_ink) outline-none',
'placeholder:text-(--_placeholder) disabled:cursor-not-allowed',
'pointer-coarse:text-base',
size === 'sm' && 'text-[13px] leading-5',
size === 'md' && 'text-sm leading-5',
size === 'lg' && 'text-base leading-6',
!clearable && !shortcut && !showSubmit && 'pe-3'
]}
/>
{#if clearable || shortcut || showSubmit}
<div class={['flex shrink-0 items-stretch', showSubmit ? 'pe-1' : 'pe-2']}>
{#if clearable}
<button
type="button"
class="search-query-field__clear group/clear grid w-8 place-items-center text-(--_muted) outline-none disabled:cursor-not-allowed pointer-coarse:w-11"
aria-label={clearLabel}
{disabled}
onclick={clear}
>
<span
class={[
'grid place-items-center rounded-full transition-[background-color,color] duration-150 ease-(--_ease) motion-reduce:transition-none',
'group-hover/clear:bg-(--_fill-hover) group-hover/clear:text-(--_ink) group-disabled/clear:bg-transparent',
'group-focus-visible/clear:outline-2 group-focus-visible/clear:outline-offset-2 group-focus-visible/clear:outline-(--_accent)',
size === 'sm' ? 'size-5' : 'size-6'
]}
>
<svg class="size-3.5" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="m4.5 4.5 7 7m0-7-7 7"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
/>
</svg>
</span>
</button>
{:else if shortcut}
<!-- A hint for keyboards: gone once the field has focus, a query, or a touch screen. -->
<span
class="flex items-center group-focus-within:hidden pointer-coarse:hidden"
aria-hidden="true"
>
<kbd
class="grid h-5 min-w-5 place-items-center rounded-(--_r-inner) border border-(--_hairline-soft) px-1 font-sans text-xs leading-none font-medium text-(--_muted)"
>{shortcut}</kbd
>
</span>
{/if}
{#if showSubmit}
<button
type="submit"
class={[
'search-query-field__submit group/submit flex justify-end py-1 ps-1 outline-none disabled:cursor-not-allowed pointer-coarse:min-w-11',
clearable ? '' : 'ms-1'
]}
{disabled}
>
<span
class={[
'flex items-center justify-center rounded-(--_r-inner) bg-(--_accent) font-medium text-(--_on-accent)',
'transition-[background-color,scale] duration-150 ease-(--_ease) motion-reduce:transition-none',
'group-hover/submit:bg-(--_accent-hover) group-disabled/submit:bg-(--_accent)',
'group-active/submit:scale-[.98] motion-reduce:group-active/submit:scale-100',
'group-focus-visible/submit:outline-2 group-focus-visible/submit:outline-offset-2 group-focus-visible/submit:outline-(--_accent)',
'aspect-square @sm:aspect-auto',
shape === 'pill' ? '@sm:px-4' : '@sm:px-3',
'pointer-coarse:text-base',
size === 'sm' && 'text-[13px] leading-5',
size === 'md' && 'text-sm leading-5',
size === 'lg' && 'text-base leading-6'
]}
>
<svg
class="size-4 @sm:hidden rtl:-scale-x-100"
viewBox="0 0 16 16"
fill="none"
aria-hidden="true"
>
<path
d="M3 8h10m-4-4 4 4-4 4"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<span class="sr-only @sm:not-sr-only">{submitLabel}</span>
</span>
</button>
{/if}
</div>
{/if}
</div>
</form>
<style>
/* Public tokens: set --search-query-field-* on the form or any ancestor to retone it. */
.search-query-field {
--_accent: var(--search-query-field-accent, #1d4ed8);
--_on-accent: var(--search-query-field-on-accent, #ffffff);
--_ink: var(--search-query-field-ink, #18181b);
--_muted: var(--search-query-field-muted, #52525b);
--_placeholder: var(--search-query-field-placeholder, #71717a);
/* 3:1 against white: the edge is the only thing marking an empty field (WCAG 1.4.11). */
--_hairline: var(--search-query-field-hairline, rgb(0 0 0 / 0.44));
--_surface: var(--search-query-field-surface, #ffffff);
/* Formulas mixed from the tokens, so hover and the hint chip follow any retone. */
--_hairline-soft: color-mix(in oklab, var(--_hairline) 45%, transparent);
--_fill-hover: color-mix(in oklab, var(--_surface), var(--_ink) 7%);
--_accent-hover: color-mix(in oklab, var(--_accent), var(--_surface) 15%);
--_ease: cubic-bezier(0.2, 0, 0, 1);
/* Height per size. The inner radius sits 5 px in (the border and the submit's inset), so
the submit button and hint chip nest concentrically inside the field. */
--_h: 2.25rem;
--_r: 0.5rem;
--_r-inner: max(2px, calc(var(--_r) - 5px));
--_edge: var(--_hairline);
}
.search-query-field[data-size='sm'] {
--_h: 2rem;
--_r: 0.375rem;
}
.search-query-field[data-size='lg'] {
--_h: 2.5rem;
}
.search-query-field[data-shape='pill'] {
--_r: 9999px;
--_r-inner: 9999px;
}
@media (pointer: coarse) {
/* The border sits inside the height, so the input and buttons inside still clear 44 px. */
.search-query-field[data-size] {
--_h: calc(2.75rem + 2px);
}
}
/*
* The edge and halo change through the :has() rules below, which follow the input inside, so
* the transition that animates them lives here with them.
*/
.search-query-field__control {
transition-property: border-color, box-shadow;
transition-duration: 150ms;
transition-timing-function: var(--_ease);
}
@media (prefers-reduced-motion: reduce) {
.search-query-field__control {
transition: none;
}
}
/* Hover only where typing is possible: one step darker edge. */
@media (hover: hover) {
.search-query-field__control:hover:not(:has(input:disabled)) {
--_edge: color-mix(in oklab, var(--_hairline), var(--_ink) 25%);
}
}
/* Focus: a two-pixel edge in the accent (border plus one ring) around the whole shape. It stays
while focus is on the clear or submit button, which draw their own ring inside it. */
.search-query-field__control:has(:focus-visible) {
border-color: var(--_accent);
box-shadow:
0 0 0 1px var(--_accent),
0 0 0 4px color-mix(in oklab, var(--_accent) 14%, transparent);
}
/* Disabled: raised fill, faded, not interactive. */
.search-query-field__control:has(input:disabled) {
background-color: color-mix(in oklab, var(--_surface), var(--_ink) 4%);
opacity: 0.55;
cursor: not-allowed;
}
/* WebKit and Blink draw their own clear cross on type=search; the field has its own. */
.search-query-field__input::-webkit-search-cancel-button,
.search-query-field__input::-webkit-search-decoration {
-webkit-appearance: none;
appearance: none;
}
/* Forced colours drop shadows and repaint borders, so focus becomes a system-colour outline. */
@media (forced-colors: active) {
.search-query-field__control:has(:focus-visible) {
outline: 2px solid Highlight;
outline-offset: 2px;
}
}
</style>
Usage#
On this pageGive it a label and an action and it renders a search form that submits GET ?q= to that page, with or without JavaScript. Pass onSearch to handle submission yourself, or onInput for live filtering. It does not fetch or show results, suggest queries, or keep search history; those are yours.
- Suggested location
src/lib/components/search-query-field-01- Required props
label
Limitations
- The form is a size container, so it takes its width from its parent. In a flex row give it a width or
flex-1through class, or it collapses. - The clear button appears once the page has hydrated; before that the field still submits and can be edited, but not cleared with one press.
- shortcut takes one printable key such as '/'. Combinations such as Ctrl+K are not handled; bind those yourself and point them at the input.
- A single-key shortcut such as '/' must be switchable off by the person using the site (WCAG 2.1.4). The component turns it off when shortcut is undefined; the setting that drives that is yours.
onSearchreceives the raw query. Trim it, and decide what an empty search means, in your handler or on the results page.- It has no results, suggestion list or combobox behaviour. Announce result counts from your own results region, not on every keystroke.
Example
<script lang="ts">
import SearchQueryField from '$lib/components/search-query-field-01/SearchQueryField.svelte';
</script>
<header class="flex items-center gap-6 px-4 py-3">
<a href="/" class="font-semibold">Halcyon</a>
<SearchQueryField
label="Search Halcyon docs"
placeholder="Search the docs"
action="/docs/search"
shortcut="/"
class="ms-auto w-full max-w-72"
/>
</header>The source imports its own files by relative path only. Examples import from $lib/...; SvelteKit 3 projects have no $lib alias by default, so import from your #lib subpath (with the .svelte extension) or a relative path instead.
Search query field#
A search box that is a plain GET form first. With no script at all, typing and pressing Enter
goes to action?q=…, which is how site and docs search should work. JavaScript adds the clear
button, Escape to clear, the shortcut and the two callbacks.
<SearchQueryField
label="Search Halcyon docs"
placeholder="Search the docs"
action="/docs/search"
shortcut="/"
class="w-full max-w-72"
/>Give it a width#
The form is a container (its submit button collapses to an arrow under 24rem), so it sizes from
its parent rather than its content. In a flex header pass class="flex-1" or a width.
Handling the search yourself#
onSearch turns off navigation and hands you the query. Keep it off when a results page exists:
the GET form already works, survives a failed script and gives every search a URL.
<SearchQueryField
label="Search orders"
onSearch={(q) => goto(`/orders?q=${encodeURIComponent(q)}`)}
/>Live filtering#
onInput fires once typing pauses for debounceMs (250 by default), never on mount, and straight
away with '' when the field is cleared. Use it to filter a list that is already on the page,
and announce the count from your own status region:
<script lang="ts">
let filter = $state('');
const shown = $derived(boards.filter((b) => b.name.toLowerCase().includes(filter.toLowerCase())));
</script>
<SearchQueryField label="Filter boards" size="sm" onInput={(q) => (filter = q.trim())} />
<p role="status">{shown.length} of {boards.length} boards</p>If both callbacks are set, pressing Enter flushes a pending onInput before onSearch, so the
two never disagree.
Shortcut#
shortcut="/" focuses the field when / is pressed anywhere on the page, unless focus is in
another field or a modifier key is held. The chip showing it hides on focus, once there is a
query, and on touch screens. Only single keys are handled.
A single-character shortcut gets in the way of people using speech input or switching keyboards, so WCAG 2.1.4 asks for a way to turn it off. Drive the prop from a setting:
<SearchQueryField label="Search Halcyon docs" shortcut={prefs.shortcuts ? '/' : undefined} />Retoning#
On a #09090b header:
.header {
background: #09090b;
color-scheme: dark;
--search-query-field-accent: #fafafa;
--search-query-field-on-accent: #09090b;
--search-query-field-ink: #fafafa;
--search-query-field-muted: #a1a1aa;
--search-query-field-placeholder: #a1a1aa;
--search-query-field-hairline: rgb(255 255 255 / 0.35);
--search-query-field-surface: #18181b;
}Props and content inputs#
On this page| Name | Type | Required | Default | Description |
|---|---|---|---|---|
label | string | Yes | None | Accessible label for the input, also the search landmark's name, for example 'Search articles'. Visually hidden unless labelVisible. |
value | string | No | '' | Bindable query. |
labelVisible | boolean | No | false | Show the label above the field. Off, it stays in the markup for assistive technology. |
action | string | No | None | Form action for GET submission. Omitted, the form submits to the current page. |
name | string | No | 'q' | Query parameter name. |
onSearch | (query: string) => void | No | None | When supplied, submission is intercepted and this is called with the query instead of navigating. A pending onInput fires first. |
onInput | (query: string) => void | No | None | Called with the query once typing pauses for debounceMs, and straight away with '' when the field is cleared. Never called on mount. |
debounceMs | number | No | 250 | Pause after the last keystroke before onInput fires, in milliseconds. |
showSubmit | boolean | No | false | Render a submit button inset at the end of the field. Under 24rem wide it shows an arrow and keeps submitLabel as its name. |
submitLabel | string | No | 'Search' | Submit button text, and its accessible name when it collapses to an arrow. |
clearLabel | string | No | 'Clear search' | Accessible name of the clear button. |
shortcut | string | No | None | One key, such as '/', that focuses the field from anywhere on the page unless someone is typing in another field. Shown as a hint chip on keyboard devices and set as aria-keyshortcuts. Pass undefined to turn it off; offer people a setting for that. |
shape | 'rounded' | 'pill' | No | 'rounded' | 'rounded' for app toolbars and docs, 'pill' for consumer sites. The submit button and hint chip follow the shape. |
size | 'sm' | 'md' | 'lg' | No | 'md' | 32, 36 or 40 px tall with 13, 14 or 16 px text. On touch screens every size is 46 px with 16 px text, so the input inside the border clears 44 px. |
disabled | boolean | No | false | Disables the input and its clear and submit buttons, and the shortcut. |
onkeydown | KeyboardEventHandler<HTMLInputElement> | No | None | Keydown on the input, called before the field's own Escape handling; call preventDefault to keep Escape from clearing. |
id | string | No | None | Input id. Defaults to one generated per instance; keep a supplied one unique on the page. |
class | string | No | None | Extra classes on the form, for placement and width. |
Customization#
On this pageChange the copy through props and retone the field through seven --search-query-field-* CSS variables. Hover, the hint chip and the submit hover are mixed from those, so they follow any retone.
- Accent:
--search-query-field-accentfills the submit button and colours the focus edge, its halo and the button focus rings;--search-query-field-on-accentis the submit text. Keep the accent at 3:1 against the page. - Neutrals:
--search-query-field-inkis the query,--search-query-field-mutedthe magnifier, clear cross and hint,--search-query-field-placeholderthe placeholder,--search-query-field-hairlinethe resting edge (keep it at 3:1, since it is the only thing marking an empty field) and--search-query-field-surfacethe fill. - Worked retone for a
#09090bheader:--search-query-field-accent:#fafafa;--search-query-field-on-accent:#09090b;--search-query-field-ink:#fafafa;--search-query-field-muted:#a1a1aa;--search-query-field-placeholder:#a1a1aa;--search-query-field-hairline: rgb(255 255 255 / 0.35);--search-query-field-surface:#18181b. Add color-scheme: dark on the header. - Cream page such as a bakery's
#fbf7f0: leave the hairline alone (it is alpha black, so it tints with the page), set--search-query-field-surface:#fffdf9and use shape 'pill'. - Sizes: sm in toolbars and list filters, md in app and docs headers, lg in help-centre heroes and marketing pages.
- Width: the form fills its container. In a header, pass class '
w-fullmax-w-72' or 'flex-1'; on a results page let it run to the column width. - Behaviour: leave
onSearchoff to keep the plain GET navigation that works without JavaScript. UseonInputfor filtering a list already on the page, and raisedebounceMsif each call hits a server. - Translate label, placeholder,
submitLabelandclearLabelwith the page; the icons and buttons follow dir=rtl.
Public CSS variables
| Variable | Token |
|---|---|
--search-query-field-accent | accent |
--search-query-field-on-accent | onAccent |
--search-query-field-ink | ink |
--search-query-field-muted | muted |
--search-query-field-placeholder | placeholder |
--search-query-field-hairline | hairline |
--search-query-field-surface | surface |
Accessibility#
On this page- The form has
role=searchand is named by the label througharia-labelledby. With two searches on one page (site and list filter), give them labels that tell them apart. - A native <label for> names the input even when it is visually hidden. The placeholder is a hint, never the label.
- The clear button is named by
clearLabel('Clear search'). It empties the field and moves focus back to the input, so keyboard and screen reader users stay where they were. - Escape clears a non-empty field. In an empty field it is left alone, so a dialog or menu around the field can still close on it.
- Nothing is announced while typing. When
onInputfilters a list, announce the result count from your own status region, as the live-filter fixture does. - The input sets
enterkeyhint=search,autocomplete=offand spellcheck=false; pass your own values to override them. The native WebKit clear cross is hidden so there is only one clear button. - The shortcut is ignored while focus is in another field or a modifier is held, and the hint chip is hidden from assistive technology;
aria-keyshortcutscarries it instead. A single-character shortcut needs a way for people to turn it off (WCAG 2.1.4): bind shortcut to a user setting and pass undefined when it is off. - A readonly query can be focused, selected and submitted, but the clear button and Escape leave it alone. Escape pressed during IME composition goes to the input method, not the field.
- Focus draws a two-pixel accent edge around the whole shape, kept while focus is on the clear or submit button, which also get their own two-pixel ring. In forced-colours mode focus becomes a Highlight outline.
Release details#
On this page- Integration
- Local interaction
- Works without client-side JavaScript
- Server-side rendering supported
- Dependencies
- No additional runtime packages beyond Svelte and Tailwind CSS
- License
MIT. Default license approval is pending; see the license status before adopting the source.
- Version history
- 1.0.0 (Published) Current release · 22 October 2026
Only the current release is available. Keep downloaded source and its receipt if you need to use it again later.