Search
Search is a text field specialized for finding things, such as a site-wide search bar or a filter box above a list. It builds on the input field, adding a locked search type, an optional global keyboard shortcut, and an optional command-palette panel that opens the field in a modal instead of always showing it inline.
Dependencies
When to use
Use search for the one field on a page whose job is finding something,
such as a site-wide search bar or a filter box above a list or table. It
builds on
Install & usage
Pick a framework in the toolbar above and these snippets adapt.
npm install @eunomia/elements
import "@eunomia/elements/search.js";
<eun-search name="search" placeholder="Search…" shortcut></eun-search>
Importing the file registers <eun-search> as a custom element, with no
further setup needed. It works with any framework, or none, since it's a
standard web component.
npm install @eunomia/elements
<script type="module">
import "@eunomia/elements/search.js";
</script>
<eun-search name="search" placeholder="Search…" shortcut></eun-search>
npm install @eunomia/elements
import "@eunomia/elements/search.js";
function SiteSearch() {
return <eun-search name="search" placeholder="Search…" shortcut />;
}
npm install @eunomia/elements
"use client";
import "@eunomia/elements/search.js";
export function SiteSearch() {
return <eun-search name="search" placeholder="Search…" shortcut />;
}
npm install @eunomia/elements
<script setup>
import "@eunomia/elements/search.js";
</script>
<template>
<eun-search name="search" placeholder="Search…" shortcut />
</template>
npm install @eunomia/elements
import { CUSTOM_ELEMENTS_SCHEMA, Component } from "@angular/core";
import "@eunomia/elements/search.js";
@Component({
selector: "app-site-search",
template: `<eun-search
name="search"
placeholder="Search…"
shortcut
></eun-search>`,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class SiteSearchComponent {}
Alternatives
eun-inputGuidance
- Set
shortcutonly for a page's primary search: having several Ctrl+K listeners on one page fires all of them at once - Pair
panelwith aresults-slotted list, kept in sync with the field'seunchangeevent - Call
closePanel()from your result-selection handler once the user picks a result - Use
suggestionsfor a fixed, known option set (recent searches, a short static list), and pair it withloadingwhen the options themselves come from a server
- Changing
type: it's locked to"search"and setting it has no effect - Relying on
min/max/step/readonly-available: inherited fromeun-inputbut not meaningful for a live search field - Combining
suggestionswithpanel: not supported, sincesuggestionsis silently ignored whilepanelis set
Live testing
Properties
Search <eun-search>
Attributes
| Name | Type | Default | Description |
|---|---|---|---|
| type | 'text' | 'email' | 'number' | 'tel' | 'url' | 'search' | 'password' | "search" | Always `"search"` — enforced in `willUpdate`, since `EunomiaInput` declares `type` as a plain property (not an accessor), which TypeScript doesn't allow overriding as a getter/setter pair from a subclass. |
| icon | EunomiaIconName | "search" | Leading icon. Defaults to a search glyph instead of `EunomiaInput`'s unset default — still overridable via the `icon` attribute/property. |
| clearable | boolean | true | Whether a clear button is displayed when the field has a value. Defaults to `true` instead of `EunomiaInput`'s `false`. |
| shortcut | boolean | false | Enables the Ctrl+K or Cmd+K global shortcut and its visual key hint badge |
| panel | boolean | false | Opens a modal command palette panel on shortcut or click instead of focusing inline |
| aria-label-panel | string | 'Search' | The accessible name of the panel's dialog, used when label isn't set |
| loading | boolean | false | Whether a search request is currently in flight. Shows a spinning loading indicator in place of the clear button or shortcut hint on the inline field only. Ignored on the panel trigger and the panel's own field, which stay static, so reflect the loading state on the results slot instead |
| loading-label | string | 'Loading…' | The accessible label announced by the loading indicator while loading is set |
| suggestions | boolean | false | Opens a dropdown listbox showing light DOM option and optgroup children matching the typed value, highlighting the matched substring. Ignored while panel is set |
| no-results-label | string | 'No results' | Text displayed in the dropdown when no option matches, while suggestions is set |
| readonly-available | boolean | false | Whether the field can switch between a static display and an editable one |
| default-value | string | — | The default value applied when the field connects, and restored on form reset |
| placeholder | string | — | The placeholder shown when the field is empty |
| autocomplete | string | 'off' | The native autocomplete attribute forwarded to the field |
| minlength | number | — | The native minimum length constraint forwarded to the field |
| maxlength | number | — | The native maximum length constraint forwarded to the field |
| pattern | string | — | The native pattern constraint forwarded to the field |
| min | string | — | The native minimum constraint forwarded to the field |
| max | string | — | The native maximum constraint forwarded to the field |
| step | string | — | The native step constraint forwarded to the field |
| empty-value-label | string | 'No value set' | Text displayed in place of the value when readonly and empty |
| confirm-label | string | 'Save' | The label of the confirm button in edit mode |
| cancel-label | string | 'Cancel' | The label of the cancel button in edit mode |
| aria-label-clear | string | 'Clear field' | The accessible label of the clear button |
| aria-label-show-password | string | 'Show password' | The accessible label of the show password button |
| aria-label-hide-password | string | 'Hide password' | The accessible label of the hide password button |
Import the exact TypeScript type behind any property above, see
Properties
JS-only — no matching HTML attribute, set these from a script or a template binding.
| Name | Type | Default | Description |
|---|---|---|---|
| checkLocalValidators | — | Reads native HTML constraint validation from the underlying field element (`required`, `minlength`, `maxlength`, `pattern`, `min`, `max`, `type="email"`, etc.). | |
| variant | 'outline' | 'fill' | 'underline' | 'outline' | The visual variant to apply to the field |
| label | string | — | The title label of the field, rendered through the label component |
| instructions | string | — | Instructions displayed below the label |
| name | string | — | The field name |
| value | string | — | The field value |
| disabled | boolean | false | Whether the field is disabled |
| readonly | boolean | false | Whether the field is read only |
| required | boolean | false | Whether the field is required |
| hideError | boolean | false | Whether the errors are hidden |
| validators | Array<Validators<string>> | — | The list of validation rules applied to the field value |
| readonlyAvailable | boolean | false | Whether the field can switch between a static display and an editable one |
Slots
| Name | Description |
|---|---|
| icon | Replaces the leading icon entirely with any content, not just an icon element. Falls back to icon when empty. Applies to the real field, inline or inside the panel modal. The compact panel trigger button always shows the plain icon glyph, since it can't project the same slotted content a second time |
| results | Content rendered below the field inside the panel, such as a result list. Only relevant when panel is set |
Events
| Name | Type | Description |
|---|---|---|
| eunchange | ChangeEvent | Fired on every value change |
| euncommit | CommitEvent | Fired when an edit is confirmed in readonlyAvailable mode |
Every event above follows the same naming convention, covered in
CSS custom properties
| Name | Description |
|---|---|
| --field-listbox-max-height | Sets the maximum height of the suggestions dropdown, past which it scrolls. Shared with the select component |
| --field-listbox-transition-duration | Sets the duration of the suggestions dropdown's open and close transition. Shared with the select component |
| --field-match-color | Sets the color of the matched search substring, highlighted while suggestions is set. Shared with the select component |
| --field-match-background | Sets the background of the matched search substring, none by default. Shared with the select component |
| --field-match-font-weight | Sets the font weight of the matched search substring. Shared with the select component |
| --field-match-text-decoration | Sets the text decoration, such as underline, of the matched search substring, none by default. Shared with the select component |
| --eun-viewport-inset-top | Reserves space, such as a page's sticky header height, that the suggestions dropdown keeps clear of at the top of the viewport. Set on the root or an ancestor rather than on the field itself |
| --eun-viewport-inset-bottom | The same as eun-viewport-inset-top, reserved at the bottom of the viewport, such as for a sticky footer |
| --field-background | Sets the background color of the field |
| --field-border-color | Sets the border color of the field |
| --field-border-color-hover | Sets the border color on hover |
| --field-border-color-focus | Sets the border color on focus |
| --field-border-radius | Sets the corner radius of the field |
| --field-padding | Sets the padding of the field |
| --field-text-color | Sets the text color of the field |
| --field-placeholder-color | Sets the color of the placeholder |
| --field-icon-color | Sets the color of the leading icon and action buttons |
| --field-error-color | Sets the color of the error message |
| --field-hint-color | Sets the color of the hint message |
| --field-focus-outline-color | Sets the color of the focus outline |
| --field-edit-confirm-color | Sets the color of the confirm button in readonly-available edit mode |
| --field-edit-cancel-color | Sets the color of the cancel button in readonly-available edit mode |
Basic
<eun-search name="search" placeholder="Search…"></eun-search>
Custom icon
icon (inherited from eun-input, defaulting here to a search glyph)
still accepts any other eun-icon name.
<eun-search
name="search"
icon="saved_search"
placeholder="Search…"
></eun-search>
Need something other than a eun-icon, or a different icon library
entirely? Slot your own content into icon instead ; it replaces the
prop-driven icon entirely (the compact panel trigger button, when used,
keeps showing the plain icon glyph, since it can't project the same
slotted node a second time).
<eun-search name="search" placeholder="Search…">
<svg
slot="icon"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="11" cy="11" r="7"></circle>
<path d="m20 20-3.5-3.5"></path>
</svg>
</eun-search>
Variants
variant (inherited from eun-input) selects the visual style: outline
(default, bordered box), fill (filled background, no border), or
underline (bottom border only), the same three variants as every other
text-like field in the library.
<eun-search name="search" placeholder="Fill" variant="fill"></eun-search>
With panel set, the collapsed trigger button mirrors the same variant,
since it's a separate, hand-rolled element (not the inherited field itself),
kept in sync with its own styling rather than looking like outline
regardless of what's set:
<eun-search name="search" placeholder="Fill" panel variant="fill"></eun-search>
Part of the shared vocabulary covered in
Keyboard shortcut
<eun-search name="search" placeholder="Search…" shortcut></eun-search>
This demo leaves out shortcut so it doesn't compete with the documentation site's own header search, which already owns Ctrl+K/⌘K on every page (including this one) — press it now to try the real thing. On a page with a single search field, add shortcut as shown above.
Pressing Ctrl+K (or ⌘K on Mac) anywhere on the page focuses the field. The
key-hint badge inside the field is purely decorative (aria-hidden), while
the field itself carries aria-keyshortcuts="Control+K Meta+K" while
shortcut is set (see the Accessibility tab).
The badge's displayed label, ⌘K versus Ctrl+K, depends on the operating
system the browser itself reports, not on real device detection: it reads
navigator.userAgentData.platform (falling back to the older, deprecated
navigator.platform on browsers without
⌘K for anything reporting a Mac-family platform,
Ctrl+K otherwise. Both keyboard shortcuts (Control+K and Meta+K) are
always listened for regardless of the detected platform, since only the
visual badge adapts. A Mac keyboard connected to a non-Mac machine (or a
browser/OS spoofing its reported platform) still triggers the shortcut
correctly even if the badge itself shows the "wrong" one.
Modal panel
This demo also leaves out shortcut, for the same reason as the "Keyboard shortcut" example above — see its note, and the Guidance section on the Overview tab.
<eun-search name="search" placeholder="Search…" shortcut panel>
<ul slot="results" id="results"></ul>
</eun-search>
const search = document.querySelector("eun-search");
const results = document.querySelector("#results");
search.addEventListener("eunchange", () => {
// Re-render #results from search.value — the field inside the panel
// shares the same `value`/`eunchange` as the inline field.
});
// From a result's click handler, once selected:
search.closePanel();
With panel set, the visible field becomes a trigger button, and clicking
it (or Ctrl+K) opens a eun-modal[variant="search"] containing the real,
editable field plus the results slot: a flush, full-width layout (no
padding, no border, no close button) with the field's own bottom border
separating it from the result list below. See eun-modal's own "Search and
command palette" example for the layout on its own. The field carries an
Esc badge instead of the collapsed trigger's Ctrl+K/⌘K one, since
Escape is how the panel closes once open. Typing in the panel still
updates value and fires eunchange exactly like the inline field would,
but the trigger button itself stays static: it always shows placeholder,
never the current value, since it's purely an entry point into the panel
rather than a second display of what's typed there.
Suggestions
Set suggestions to open a dropdown listbox of matching option children as
the user types, highlighting the matched substring: light DOM option
(and optgroup) children, parsed and filtered exactly like
eun-selectvalue to its label (its text content, or its value
attribute if explicitly set, same as a native <option>'s own .value
property default) and closes the dropdown, and eunchange fires exactly like
picking any other value would.
<eun-search name="search" placeholder="Search…" suggestions>
<option>Getting started</option>
<option>Installation</option>
<option>Components</option>
<option>Theming</option>
<option>Accessibility</option>
</eun-search>
Not supported together with panel, since a collapsed trigger button plus
two independent top-layer light-dismiss surfaces (the modal's own and the
listbox popover's) isn't a combination this component implements.
suggestions is silently ignored while panel is set.
Dropdown positioning, sizing, and theming
Same mechanic as eun-select's own dropdown, sharing its CSS custom
properties. AnchorPositionMixin keeps the panel positioned against the
field on every scroll/resize, flipping above it when there isn't room below,
via CSS Anchor Positioning natively where supported and a JS fallback
everywhere else. See eun-select's own "Dropdown panel: rendering,
positioning, and sizing" section (Examples tab) for the full explanation,
including --eun-viewport-inset-top/-bottom for page chrome like a sticky
header. --field-listbox-max-height (default 260px) caps how tall it gets
before scrolling, while --field-match-color/-background/-font-weight/
-text-decoration theme the highlighted substring. Set any of these once
and both eun-search and eun-select pick it up.
Loading
Set loading while a search request is in flight, and a spinning
eun-loader replaces the clear button/shortcut badge, without disabling
the field, since a live search commonly keeps accepting input while the
request for the previous keystroke is still in flight.
<eun-search name="search" placeholder="Search…" loading></eun-search>
import "@eunomia/elements/loader.js";
eun-loader is used internally, but its module must still be imported alongside eun-search for the spinner to render, exactly like eun-icon for the leading icon above.
With panel set, loading is ignored on both the collapsed trigger button
and the panel's own field — both stay static entry points regardless of a
request in flight. Reflect the loading state on the results slot instead,
as in the "Debouncing a real request" example below.
Debouncing a real request
eun-search has no idea what "the request" behind loading actually is,
since that's entirely the consumer's own code, wired to eunchange.
Debouncing it so a fast typist doesn't fire one request per keystroke is
that same consumer's responsibility too, so @eunomia/elements exports a
small debounce helper for exactly this, meaning nobody has to reach for a
whole utility library (like Lodash) just for this one function, or
reimplement it by hand.
This example puts the two together inside panel's results slot: typing
debounces, loading shows while the (simulated) request is in flight, with
a eun-loader replacing the result rows entirely instead of leaving stale
or empty content on screen, then the real rows replace it once the
response is back. This is exactly why loading stays invisible on the
trigger and the panel's own field in panel mode: the list is where the
in-flight state actually belongs, not duplicated onto the field above it.
<eun-search name="search" placeholder="Search…" panel>
<ul slot="results" id="results"></ul>
</eun-search>
import { debounce } from "@eunomia/elements";
const search = document.querySelector("eun-search");
const results = document.querySelector("#results");
const runSearch = debounce(async (query: string) => {
search.loading = true;
try {
const matches = await fetchResults(query); // your own API call
renderResults(matches); // your own rendering, e.g. a eun-loader row while empty
} finally {
search.loading = false;
}
}, 300);
search.addEventListener("eunchange", () => runSearch(search.value));
300ms is a reasonable starting point for a debounce delay on a live search, long enough to skip firing on every keystroke of a fast typist, yet short enough to still feel immediate. Tune it against your own API's actual latency.
Keyboard interactions
| Key | Action |
|---|---|
Ctrl+K / ⌘K |
Focuses the field (or opens the panel, if set), only while shortcut is set, from anywhere on the page |
Tab / Shift+Tab |
Moves focus in/out of the field (inline), or the trigger button / panel content (panel mode) |
Enter / Space |
Activates the trigger button, opening the panel (panel mode only, native button semantics) |
Escape |
Closes the panel (native to eun-modal, see its own Keyboard interactions) |
Aria attributes
The key-hint badge (⌘K/Ctrl+K/Esc) is purely decorative and marked
aria-hidden="true" in every context it appears. The actual describable
shortcut lives on whichever element it really activates:
- Inline (
panelnot set): the field itself carriesaria-keyshortcuts="Control+K Meta+K"whileshortcutis set. - Panel mode: the trigger button carries
aria-keyshortcutsinstead, since the real field sits inside a closed popover until opened, and content inside a closed popover is removed from the accessibility tree entirely, so describing the shortcut there wouldn't be discoverable. The trigger button also carriesaria-haspopup="dialog".
The panel's dialog (eun-modal[variant="search"]) renders no visible
heading, so it wouldn't otherwise have an accessible name. eun-search
sets a plain aria-label on it directly: label when set (naming the
dialog the same as the field), falling back to ariaLabelPanel (default
"Search") otherwise. See eun-modal's own Accessibility tab for the
dialog's other semantics (role="dialog", focus handling, Escape), which
also covers focus management in more depth, including this component's
own case: the panel's field gets autofocus so keyboard focus lands there
immediately on open, and closing it (however that happens) returns focus to
wherever it was before, either the trigger button if that's what opened it,
or elsewhere on the page if a global Ctrl+K/⌘K press did.
Results are not a combobox/listbox
The results slot is fully consumer-defined content (any markup, a list,
a grid, grouped sections), and eun-search doesn't know its shape, so it
doesn't implement the ARIA combobox/listbox pattern (role="combobox",
aria-expanded, aria-controls, aria-activedescendant, arrow-key roving
focus between results). Tab still reaches every result normally, same as
any other focusable content, but there's no arrow-key navigation between
them out of the box. If you need that, implement roving tabindex /
aria-activedescendant yourself on your own slotted markup, following the
Reference links