Usage
Search is an inline Material 3 contained search field with an optional projected results surface. Use it when users need to find content, navigate documentation, or filter a known collection. The implementation follows the contained Search bar specification and its interaction guidelines.
Search owns the query, clear action, submit event, input semantics, and inline results visibility. It does not implement docked or full-screen presentation. Compose it with SideSheet or another surface primitive when search should become modal; results are still projected by the consumer, so filtering and selection remain in application code.
Press Enter to submit
import { useMemo, useState } from 'react';
import { Search } from '@udixio/ui-react';
const documents = ['Button', 'Card', 'Menu', 'Search', 'Text field'];
export default function SearchBasicReact() {
const [query, setQuery] = useState('');
const [submitted, setSubmitted] = useState('');
const results = useMemo(() => {
const normalizedQuery = query.trim().toLowerCase();
return documents.filter((document) =>
document.toLowerCase().includes(normalizedQuery),
);
}, [query]);
return (
<div className="flex w-full flex-col items-center gap-3">
<Search
label="Search documentation"
placeholder="Search components"
query={query}
onQueryChange={setQuery}
onSearch={setSubmitted}
>
{results.map((result) => (
<div
key={result}
role="option"
aria-selected={false}
tabIndex={-1}
className="rounded-xl px-4 py-3 text-body-large hover:bg-on-surface/[0.08]"
>
{result}
</div>
))}
</Search>
<p className="text-body-small text-on-surface-variant">
{submitted ? `Submitted: ${submitted}` : 'Press Enter to submit'}
</p>
</div>
);
}Press Enter to submit
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { Search } from '@udixio/ui-angular';
const documents = ['Button', 'Card', 'Menu', 'Search', 'Text field'];
@Component({
selector: 'docs-search-basic-angular',
standalone: true,
imports: [Search],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="flex w-full flex-col items-center gap-3">
<udx-search
label="Search documentation"
placeholder="Search components"
[(query)]="query"
(searchSubmit)="submitted = $event"
>
@for (result of filteredResults; track result) {
<div
role="option"
aria-selected="false"
tabindex="-1"
class="rounded-xl px-4 py-3 text-body-large hover:bg-on-surface/[0.08]"
>
{{ result }}
</div>
}
</udx-search>
<p class="text-body-small text-on-surface-variant">
{{ submitted ? 'Submitted: ' + submitted : 'Press Enter to submit' }}
</p>
</div>
`,
})
export class SearchBasicAngular {
protected query = '';
protected submitted = '';
protected get filteredResults(): string[] {
const normalizedQuery = this.query.trim().toLowerCase();
return documents.filter((document) =>
document.toLowerCase().includes(normalizedQuery),
);
}
}Press Enter to submit
<script lang="ts">
import { Search } from '@udixio/ui-svelte';
const documents = ['Button', 'Card', 'Menu', 'Search', 'Text field'];
let query = $state('');
let submitted = $state('');
const results = $derived(
documents.filter((document) => document.toLowerCase().includes(query.trim().toLowerCase())),
);
</script>
<div class="flex w-full flex-col items-center gap-3">
<Search label="Search documentation" placeholder="Search components" bind:query onSearch={(value) => (submitted = value)}>
{#each results as result (result)}
<div role="option" aria-selected={false} tabindex={-1} class="rounded-xl px-4 py-3 text-body-large hover:bg-on-surface/[0.08]">
{result}
</div>
{/each}
</Search>
<p class="text-body-small text-on-surface-variant">{submitted ? `Submitted: ${submitted}` : 'Press Enter to submit'}</p>
</div>Material 3 anatomy
The component exposes the Material 3 contained Search bar anatomy through its public props: the decorative leading icon, supporting placeholder text, interactive trailing actions, and a clear action when the query is non-empty. The container uses the Search bar’s persistent filled surface, rounded shape, and touch-sized action buttons without a default shadow. When expanded, it reuses the same input and exposes a labeled projected results surface below the header without a baseline divider. Its focus indicator fades in briefly instead of appearing abruptly, and the results surface uses a restrained height/opacity reveal.
The inline results stay in normal page flow. A modal search can place the same Search inside SideSheet or another dialog/surface primitive; that surrounding primitive owns the modal presentation.
The result content is deliberately consumer-owned. This keeps the component usable with local filtering, remote search, recent searches, command palettes, and custom result designs while leaving loading, errors, empty states, and selection behavior in the feature that owns the data.
Compose Search with SideSheet
Search does not import or control SideSheet. The owning feature composes the two primitives when search needs a modal presentation. SideSheet owns the scrim, anchoring and width policy, portal, focus trap, inert background, scroll lock, and dismissal behavior; Search remains responsible for the field and its local results surface. Adapt the side sheet’s position and width classes for compact or expanded windows; Search does not need a breakpoint prop. The example uses a full-width compact panel and a constrained sm width to make that composition explicit.
The example shows both usages: an inline search and the same kind of search embedded in a modal side sheet.
Modal search is closed
import { useState } from 'react';
import { Search, SideSheet } from '@udixio/ui-react';
const filters = ['Components', 'Guides', 'API reference'];
export default function SearchCompositionReact() {
const [open, setOpen] = useState(false);
return (
<div className="flex w-full flex-col items-center gap-4">
<Search label="Inline documentation search" />
<button
type="button"
className="rounded-full bg-primary px-4 py-2 text-label-large text-on-primary"
onClick={() => setOpen(true)}
>
Open search in a modal surface
</button>
<SideSheet
variant="modal"
title="Search documentation"
open={open}
onOpenChange={setOpen}
className="w-[calc(100vw-2rem)] max-w-none sm:w-96"
>
<div className="p-4">
<Search label="Filter documentation" defaultExpanded>
{filters.map((filter) => (
<div
key={filter}
role="option"
aria-selected={false}
tabIndex={-1}
className="rounded-xl px-4 py-3 text-body-large hover:bg-on-surface/[0.08]"
>
{filter}
</div>
))}
</Search>
</div>
</SideSheet>
<p className="text-body-small text-on-surface-variant" role="status">
{open ? 'Modal search is open' : 'Modal search is closed'}
</p>
</div>
);
}Modal search is closed
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { Search, SideSheet } from '@udixio/ui-angular';
const filters = ['Components', 'Guides', 'API reference'];
@Component({
selector: 'docs-search-composition-angular',
standalone: true,
imports: [Search, SideSheet],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="flex w-full flex-col items-center gap-4">
<udx-search label="Inline documentation search" />
<button
type="button"
class="rounded-full bg-primary px-4 py-2 text-label-large text-on-primary"
(click)="open = true"
>
Open search in a modal surface
</button>
<udx-side-sheet
variant="modal"
title="Search documentation"
[open]="open"
(openChange)="open = $event"
class="w-[calc(100vw-2rem)] max-w-none sm:w-96"
>
<div class="p-4">
<udx-search label="Filter documentation" defaultExpanded>
@for (filter of filters; track filter) {
<div
role="option"
aria-selected="false"
tabindex="-1"
class="rounded-xl px-4 py-3 text-body-large hover:bg-on-surface/[0.08]"
>
{{ filter }}
</div>
}
</udx-search>
</div>
</udx-side-sheet>
<p class="text-body-small text-on-surface-variant" role="status">
{{ open ? 'Modal search is open' : 'Modal search is closed' }}
</p>
</div>
`,
})
export class SearchCompositionAngular {
protected readonly filters = filters;
protected open = false;
}Modal search is closed
<script lang="ts">
import { Button, Search, SideSheet } from '@udixio/ui-svelte';
const filters = ['Components', 'Guides', 'API reference'];
let open = $state(false);
</script>
<div class="flex w-full flex-col items-center gap-4">
<Search label="Inline documentation search" />
<Button label="Open search in a modal surface" onclick={() => (open = true)} />
<SideSheet variant="modal" title="Search documentation" bind:open class="w-[calc(100vw-2rem)] max-w-none sm:w-96">
<div class="p-4">
<Search label="Filter documentation" defaultExpanded>
{#each filters as filter (filter)}
<div role="option" aria-selected={false} tabindex={-1} class="rounded-xl px-4 py-3 text-body-large hover:bg-on-surface/[0.08]">
{filter}
</div>
{/each}
</Search>
</div>
</SideSheet>
<p class="text-body-small text-on-surface-variant" role="status">{open ? 'Modal search is open' : 'Modal search is closed'}</p>
</div>Uncontrolled defaults
Use defaultQuery and defaultExpanded when the Search component should own its state. defaultExpanded controls the initial visibility of the projected local results surface; it does not open or close a surrounding SideSheet or dialog. This example still observes onQueryChange and onSearch so an application can react to user input without turning the field into a fully controlled component.
Current query: Material 3 · Submitted: not yet
import { useState } from 'react';
import { Search } from '@udixio/ui-react';
const recentSearches = ['Material 3', 'Search', 'Text field'];
export default function SearchUncontrolledReact() {
const [lastQuery, setLastQuery] = useState('Material 3');
const [submittedQuery, setSubmittedQuery] = useState('');
return (
<div className="flex w-full flex-col items-center gap-3">
<Search
label="Recent searches"
defaultQuery="Material 3"
defaultExpanded
onQueryChange={setLastQuery}
onSearch={setSubmittedQuery}
>
{recentSearches.map((result) => (
<div
key={result}
role="option"
aria-selected={false}
tabIndex={-1}
className="rounded-xl px-4 py-3 text-body-large hover:bg-on-surface/[0.08]"
>
{result}
</div>
))}
</Search>
<p className="text-body-small text-on-surface-variant" role="status">
Current query: {lastQuery || 'empty'} · Submitted:{' '}
{submittedQuery || 'not yet'}
</p>
</div>
);
}Current query: Material 3 · Submitted: not yet
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { Search } from '@udixio/ui-angular';
const recentSearches = ['Material 3', 'Search', 'Text field'];
@Component({
selector: 'docs-search-uncontrolled-angular',
standalone: true,
imports: [Search],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="flex w-full flex-col items-center gap-3">
<udx-search
label="Recent searches"
defaultQuery="Material 3"
defaultExpanded
(queryChange)="lastQuery = $event"
(searchSubmit)="submittedQuery = $event"
>
@for (result of recentSearches; track result) {
<div
role="option"
aria-selected="false"
tabindex="-1"
class="rounded-xl px-4 py-3 text-body-large hover:bg-on-surface/[0.08]"
>
{{ result }}
</div>
}
</udx-search>
<p class="text-body-small text-on-surface-variant" role="status">
Current query: {{ lastQuery || 'empty' }} · Submitted:
{{ submittedQuery || 'not yet' }}
</p>
</div>
`,
})
export class SearchUncontrolledAngular {
protected readonly recentSearches = recentSearches;
protected lastQuery = 'Material 3';
protected submittedQuery = '';
}Current query: Material 3 · Submitted: not yet
<script lang="ts">
import { Search } from '@udixio/ui-svelte';
const recentSearches = ['Material 3', 'Search', 'Text field'];
let lastQuery = $state('Material 3');
let submittedQuery = $state('');
</script>
<div class="flex w-full flex-col items-center gap-3">
<Search label="Recent searches" defaultQuery="Material 3" defaultExpanded onQueryChange={(value) => (lastQuery = value)} onSearch={(value) => (submittedQuery = value)}>
{#each recentSearches as result (result)}
<div role="option" aria-selected={false} tabindex={-1} class="rounded-xl px-4 py-3 text-body-large hover:bg-on-surface/[0.08]">{result}</div>
{/each}
</Search>
<p class="text-body-small text-on-surface-variant" role="status">
Current query: {lastQuery || 'empty'} · Submitted: {submittedQuery || 'not yet'}
</p>
</div>Controlled and uncontrolled state
Use query with onQueryChange in React, [(query)] in Angular, or bind:query in Svelte, when the owning feature controls the query. defaultQuery initializes uncontrolled usage. The same pattern applies to expanded/onExpandedChange and defaultExpanded; those callbacks control only Search’s local results visibility. If Search is inside a modal surface, control that surface with its own open API.
Svelte renders projected results and trailing actions as snippets.
onSearch (React) or (searchSubmit) (Angular) receives the current query when the user presses Enter or invokes the search action from an input method editor.
Disabled, read-only, and clearable states
disabled prevents interaction and native input focus. readOnly keeps the value visible while preventing edits, and clearable={false} removes the automatic clear action without preventing the user from editing the query. The focus and blur callbacks remain available for integrating surrounding feature state.
Focus the read-only field
import { useState } from 'react';
import { Search } from '@udixio/ui-react';
export default function SearchStatesReact() {
const [focusState, setFocusState] = useState('Focus the read-only field');
return (
<div className="flex w-full flex-col gap-4">
<div className="grid w-full gap-4 md:grid-cols-3">
<Search
label="Unavailable search"
defaultQuery="Unavailable"
disabled
/>
<Search
label="Read-only search"
query="Locked query"
readOnly
clearable={false}
onFocus={() => setFocusState('Read-only field focused')}
onBlur={() => setFocusState('Read-only field blurred')}
/>
<Search
label="Search without clear action"
defaultQuery="Persistent filter"
clearable={false}
/>
</div>
<p className="text-body-small text-on-surface-variant" role="status">
{focusState}
</p>
</div>
);
}Focus the read-only field
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { Search } from '@udixio/ui-angular';
@Component({
selector: 'docs-search-states-angular',
standalone: true,
imports: [Search],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="flex w-full flex-col gap-4">
<div class="grid w-full gap-4 md:grid-cols-3">
<udx-search
label="Unavailable search"
defaultQuery="Unavailable"
disabled
/>
<udx-search
label="Read-only search"
[query]="lockedQuery"
readOnly
[clearable]="false"
(focused)="focusState = 'Read-only field focused'"
(blurred)="focusState = 'Read-only field blurred'"
/>
<udx-search
label="Search without clear action"
defaultQuery="Persistent filter"
[clearable]="false"
/>
</div>
<p class="text-body-small text-on-surface-variant" role="status">
{{ focusState }}
</p>
</div>
`,
})
export class SearchStatesAngular {
protected readonly lockedQuery = 'Locked query';
protected focusState = 'Focus the read-only field';
}Focus the read-only field
<script lang="ts">
import { Search } from '@udixio/ui-svelte';
let focusState = $state('Focus the read-only field');
</script>
<div class="flex w-full flex-col gap-4">
<div class="grid w-full gap-4 md:grid-cols-3">
<Search label="Unavailable search" defaultQuery="Unavailable" disabled />
<Search
label="Read-only search"
query="Locked query"
readOnly
clearable={false}
onFocus={() => (focusState = 'Read-only field focused')}
onBlur={() => (focusState = 'Read-only field blurred')}
/>
<Search label="Search without clear action" defaultQuery="Persistent filter" clearable={false} />
</div>
<p class="text-body-small text-on-surface-variant" role="status">{focusState}</p>
</div>Leading icon and trailing actions
leadingIcon accepts the search icon, or another decorative icon, shown at the start of the field. It is not a button: clicking the field still focuses the input, while navigation/back/close belongs to the surrounding SideSheet or surface. For the right side, use trailingActions in React or project one or two udx-icon-button[search-trailing] controls in Angular. These are real interactive actions, not bare Icon definitions. Use them for additional search modes such as voice search, filters, settings, or another high-level action.
In Material 3 terminology, “icon” describes the visual element; when it triggers an interaction, its container is an IconButton. Search therefore receives action controls and leaves their inner Icon to IconButton.
Material 3 allows one or two trailing icons or icon buttons. Search’s built-in clear action counts as one while it is visible, so combine it with at most one custom trailing action. Set clearable={false} when two custom trailing actions must remain visible. Modal or side-sheet close actions remain owned by the surrounding surface.
import { useState } from 'react';
import { iFilter } from '@udixio/icons-rounded-400/filter';
import { iHistory } from '@udixio/icons-rounded-400/history';
import { iSettings } from '@udixio/icons-rounded-400/settings';
import { iTune } from '@udixio/icons-rounded-400/tune';
import { IconButton, Search } from '@udixio/ui-react';
const history = ['Material search', 'Search accessibility', 'Search patterns'];
export default function SearchIconsReact() {
const [query, setQuery] = useState('');
const [expanded, setExpanded] = useState(false);
const [filtersOpen, setFiltersOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
return (
<div className="flex w-full flex-col gap-4">
<Search
label="Filter results"
defaultQuery="Material"
leadingIcon={iFilter}
clearLabel="Remove filter"
trailingActions={
<IconButton
icon={iTune}
label="Open filters"
tooltip={false}
toggleable
pressed={filtersOpen}
onPressedChange={setFiltersOpen}
/>
}
/>
<Search
label="Search history"
query={query}
expanded={expanded}
onQueryChange={setQuery}
onExpandedChange={setExpanded}
leadingIcon={iHistory}
clearable={false}
clearLabel="Clear history query"
resultsLabel="Search history suggestions"
trailingActions={
<>
<IconButton
icon={iSettings}
label="Open search settings"
tooltip={false}
toggleable
pressed={settingsOpen}
onPressedChange={setSettingsOpen}
/>
<IconButton
icon={iTune}
label="Open search filters"
tooltip={false}
onClick={() => setFiltersOpen(true)}
/>
</>
}
>
{history.map((result) => (
<div
key={result}
role="option"
aria-selected={false}
tabIndex={-1}
className="rounded-xl px-4 py-3 text-body-large hover:bg-on-surface/[0.08]"
>
{result}
</div>
))}
</Search>
</div>
);
}import { ChangeDetectionStrategy, Component } from '@angular/core';
import { iFilter } from '@udixio/icons-rounded-400/filter';
import { iHistory } from '@udixio/icons-rounded-400/history';
import { iSettings } from '@udixio/icons-rounded-400/settings';
import { iTune } from '@udixio/icons-rounded-400/tune';
import { IconButton, Search } from '@udixio/ui-angular';
const history = ['Material search', 'Search accessibility', 'Search patterns'];
@Component({
selector: 'docs-search-icons-angular',
standalone: true,
imports: [IconButton, Search],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="flex w-full flex-col gap-4">
<udx-search
label="Filter results"
defaultQuery="Material"
[leadingIcon]="filterIcon"
clearLabel="Remove filter"
>
<udx-icon-button
search-trailing
[icon]="tuneIcon"
label="Open filters"
[tooltip]="false"
toggleable
[pressed]="filtersOpen"
(pressedChange)="filtersOpen = $event"
/>
</udx-search>
<udx-search
label="Search history"
[(query)]="query"
[(expanded)]="expanded"
[leadingIcon]="historyIcon"
[clearable]="false"
clearLabel="Clear history query"
resultsLabel="Search history suggestions"
>
<udx-icon-button
search-trailing
[icon]="settingsIcon"
label="Open search settings"
[tooltip]="false"
toggleable
[pressed]="settingsOpen"
(pressedChange)="settingsOpen = $event"
/>
<udx-icon-button
search-trailing
[icon]="tuneIcon"
label="Open search filters"
[tooltip]="false"
(click)="filtersOpen = true"
/>
@for (result of history; track result) {
<div
role="option"
aria-selected="false"
tabindex="-1"
class="rounded-xl px-4 py-3 text-body-large hover:bg-on-surface/[0.08]"
>
{{ result }}
</div>
}
</udx-search>
</div>
`,
})
export class SearchIconsAngular {
protected readonly filterIcon = iFilter;
protected readonly history = history;
protected readonly historyIcon = iHistory;
protected readonly settingsIcon = iSettings;
protected readonly tuneIcon = iTune;
protected query = '';
protected expanded = false;
protected filtersOpen = false;
protected settingsOpen = false;
}<script lang="ts">
import { iFilter } from '@udixio/icons-rounded-400/filter';
import { iHistory } from '@udixio/icons-rounded-400/history';
import { iSettings } from '@udixio/icons-rounded-400/settings';
import { iTune } from '@udixio/icons-rounded-400/tune';
import { IconButton, Search } from '@udixio/ui-svelte';
const history = ['Material search', 'Search accessibility', 'Search patterns'];
let query = $state('');
let expanded = $state(false);
let filtersOpen = $state(false);
let settingsOpen = $state(false);
</script>
{#snippet filterActions()}
<IconButton icon={iTune} label="Open filters" tooltip={false} toggleable bind:pressed={filtersOpen} />
{/snippet}
{#snippet historyActions()}
<IconButton icon={iSettings} label="Open search settings" tooltip={false} toggleable bind:pressed={settingsOpen} />
<IconButton icon={iTune} label="Open search filters" tooltip={false} onclick={() => (filtersOpen = true)} />
{/snippet}
<div class="flex w-full flex-col gap-4">
<Search label="Filter results" defaultQuery="Material" leadingIcon={iFilter} clearLabel="Remove filter" trailingActions={filterActions} />
<Search
label="Search history"
bind:query
bind:expanded
leadingIcon={iHistory}
clearable={false}
clearLabel="Clear history query"
resultsLabel="Search history suggestions"
trailingActions={historyActions}
>
{#each history as result (result)}
<div role="option" aria-selected={false} tabindex={-1} class="rounded-xl px-4 py-3 text-body-large hover:bg-on-surface/[0.08]">{result}</div>
{/each}
</Search>
</div>Results, empty, loading, and error states
Search does not fetch or filter data itself. Update the projected content from the owning feature and expose an appropriate empty, loading, or error state. When the expanded state changes, Search animates this inline surface with a short Anime.js height/opacity transition; the transition is skipped when reduced motion is requested. When the local results surface is expanded, a pointer outside Search closes it; modal dismissal remains owned by the surrounding SideSheet or surface. When Search is inside a SideSheet, the side sheet owns the modal surface while Search continues to own the labeled local results surface inside it. Selectable results should use role="option", aria-selected, and tabIndex={-1} (or tabindex="-1" in Angular); a status message can use aria-live when its content changes.
Consumer-managed state: Results available
import { useState } from 'react';
import { Search } from '@udixio/ui-react';
type ResultState = 'results' | 'empty' | 'loading' | 'error';
const results = [
'Material Search',
'Search guidelines',
'Search accessibility',
];
const stateLabels: Record<ResultState, string> = {
results: 'Results available',
empty: 'No results',
loading: 'Loading results',
error: 'Unable to load results',
};
export default function SearchResultsReact() {
const [query, setQuery] = useState('search');
const [state, setState] = useState<ResultState>('results');
const showState = (nextState: ResultState) => setState(nextState);
return (
<div className="flex w-full flex-col gap-4">
<div
className="flex flex-wrap gap-2"
role="group"
aria-label="Result state"
>
{(Object.keys(stateLabels) as ResultState[]).map((resultState) => (
<button
key={resultState}
type="button"
aria-pressed={state === resultState}
className="rounded-full bg-surface-container-high px-3 py-2 text-label-medium text-on-surface hover:bg-on-surface/[0.08]"
onClick={() => showState(resultState)}
>
{resultState}
</button>
))}
</div>
<Search
label="Remote documentation search"
query={query}
onQueryChange={setQuery}
defaultExpanded
resultsLabel="Remote search results"
onSearch={() => showState('loading')}
>
{state === 'results' ? (
results.map((result) => (
<div
key={result}
role="option"
aria-selected={false}
tabIndex={-1}
className="rounded-xl px-4 py-3 text-body-large hover:bg-on-surface/[0.08]"
>
{result}
</div>
))
) : (
<div
role="option"
aria-selected={false}
aria-disabled="true"
aria-live="polite"
tabIndex={-1}
className="px-4 py-3 text-body-medium text-on-surface-variant"
>
{stateLabels[state]}
</div>
)}
</Search>
<p className="text-body-small text-on-surface-variant" role="status">
Consumer-managed state: {stateLabels[state]}
</p>
</div>
);
}Consumer-managed state: Results available
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { Search } from '@udixio/ui-angular';
type ResultState = 'results' | 'empty' | 'loading' | 'error';
const results = [
'Material Search',
'Search guidelines',
'Search accessibility',
];
@Component({
selector: 'docs-search-results-angular',
standalone: true,
imports: [Search],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="flex w-full flex-col gap-4">
<div class="flex flex-wrap gap-2" role="group" aria-label="Result state">
@for (resultState of resultStates; track resultState) {
<button
type="button"
[attr.aria-pressed]="state === resultState"
class="rounded-full bg-surface-container-high px-3 py-2 text-label-medium text-on-surface hover:bg-on-surface/[0.08]"
(click)="showState(resultState)"
>
{{ resultState }}
</button>
}
</div>
<udx-search
label="Remote documentation search"
[(query)]="query"
defaultExpanded
resultsLabel="Remote search results"
(searchSubmit)="showState('loading')"
>
@if (state === 'results') {
@for (result of results; track result) {
<div
role="option"
aria-selected="false"
tabindex="-1"
class="rounded-xl px-4 py-3 text-body-large hover:bg-on-surface/[0.08]"
>
{{ result }}
</div>
}
} @else {
<div
role="option"
aria-selected="false"
aria-disabled="true"
aria-live="polite"
tabindex="-1"
class="px-4 py-3 text-body-medium text-on-surface-variant"
>
{{ stateLabels[state] }}
</div>
}
</udx-search>
<p class="text-body-small text-on-surface-variant" role="status">
Consumer-managed state: {{ stateLabels[state] }}
</p>
</div>
`,
})
export class SearchResultsAngular {
protected readonly resultStates: ResultState[] = [
'results',
'empty',
'loading',
'error',
];
protected readonly results = results;
protected readonly stateLabels: Record<ResultState, string> = {
results: 'Results available',
empty: 'No results',
loading: 'Loading results',
error: 'Unable to load results',
};
protected query = 'search';
protected state: ResultState = 'results';
protected showState(nextState: ResultState): void {
this.state = nextState;
}
}Consumer-managed state: Results available
<script lang="ts">
import { Search } from '@udixio/ui-svelte';
type ResultState = 'results' | 'empty' | 'loading' | 'error';
const results = ['Material Search', 'Search guidelines', 'Search accessibility'];
const stateLabels: Record<ResultState, string> = {
results: 'Results available',
empty: 'No results',
loading: 'Loading results',
error: 'Unable to load results',
};
let query = $state('search');
let state = $state<ResultState>('results');
</script>
<div class="flex w-full flex-col gap-4">
<div class="flex flex-wrap gap-2" role="group" aria-label="Result state">
{#each Object.keys(stateLabels) as resultState}
<button
type="button"
aria-pressed={state === resultState}
class="rounded-full bg-surface-container-high px-3 py-2 text-label-medium text-on-surface hover:bg-on-surface/[0.08]"
onclick={() => (state = resultState as ResultState)}
>{resultState}</button>
{/each}
</div>
<Search label="Remote documentation search" bind:query defaultExpanded resultsLabel="Remote search results" onSearch={() => (state = 'loading')}>
{#if state === 'results'}
{#each results as result (result)}
<div role="option" aria-selected={false} tabindex={-1} class="rounded-xl px-4 py-3 text-body-large hover:bg-on-surface/[0.08]">{result}</div>
{/each}
{:else}
<div role="option" aria-selected={false} aria-disabled="true" aria-live="polite" tabindex={-1} class="px-4 py-3 text-body-medium text-on-surface-variant">
{stateLabels[state]}
</div>
{/if}
</Search>
<p class="text-body-small text-on-surface-variant" role="status">Consumer-managed state: {stateLabels[state]}</p>
</div>Rich result content with resultsRole="dialog"
Use resultsRole="dialog" when the expanded surface contains actions or a rich layout rather than a list of selectable options. The component provides the labeled results surface, but the consumer remains responsible for the dialog content’s focus order, keyboard behavior, and action semantics.
Settings
This is a rich projected result, not a listbox option. The consumer owns its internal focus and actions.
No action selected
import { useState } from 'react';
import { Search } from '@udixio/ui-react';
export default function SearchDialogReact() {
const [selectedAction, setSelectedAction] = useState('No action selected');
return (
<div className="flex w-full flex-col items-center gap-3">
<Search
label="Search commands"
defaultQuery="settings"
defaultExpanded
resultsRole="dialog"
resultsLabel="Command details"
clearLabel="Clear command query"
>
<div className="flex flex-col gap-3 p-4">
<h3 className="text-title-medium">Settings</h3>
<p className="text-body-medium text-on-surface-variant">
This is a rich projected result, not a listbox option. The consumer
owns its internal focus and actions.
</p>
<button
type="button"
className="self-start rounded-full bg-primary px-4 py-2 text-label-large text-on-primary"
onClick={() => setSelectedAction('Settings opened')}
>
Open settings
</button>
</div>
</Search>
<p className="text-body-small text-on-surface-variant" role="status">
{selectedAction}
</p>
</div>
);
}No action selected
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { Search } from '@udixio/ui-angular';
@Component({
selector: 'docs-search-dialog-angular',
standalone: true,
imports: [Search],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="flex w-full flex-col items-center gap-3">
<udx-search
label="Search commands"
defaultQuery="settings"
defaultExpanded
resultsRole="dialog"
resultsLabel="Command details"
clearLabel="Clear command query"
>
<div class="flex flex-col gap-3 p-4">
<h3 class="text-title-medium">Settings</h3>
<p class="text-body-medium text-on-surface-variant">
This is a rich projected result, not a listbox option. The consumer
owns its internal focus and actions.
</p>
<button
type="button"
class="self-start rounded-full bg-primary px-4 py-2 text-label-large text-on-primary"
(click)="selectedAction = 'Settings opened'"
>
Open settings
</button>
</div>
</udx-search>
<p class="text-body-small text-on-surface-variant" role="status">
{{ selectedAction }}
</p>
</div>
`,
})
export class SearchDialogAngular {
protected selectedAction = 'No action selected';
}Settings
This is a rich projected result, not a listbox option. The consumer owns its internal focus and actions.
No action selected
<script lang="ts">
import { Search } from '@udixio/ui-svelte';
let selectedAction = $state('No action selected');
</script>
<div class="flex w-full flex-col items-center gap-3">
<Search label="Search commands" defaultQuery="settings" defaultExpanded resultsRole="dialog" resultsLabel="Command details" clearLabel="Clear command query">
<div class="flex flex-col gap-3 p-4">
<h3 class="text-title-medium">Settings</h3>
<p class="text-body-medium text-on-surface-variant">This is a rich projected result, not a listbox option. The consumer owns its internal focus and actions.</p>
<button type="button" class="self-start rounded-full bg-primary px-4 py-2 text-label-large text-on-primary" onclick={() => (selectedAction = 'Settings opened')}>Open settings</button>
</div>
</Search>
<p class="text-body-small text-on-surface-variant" role="status">{selectedAction}</p>
</div>Native form integration and input attributes
The native search input forwards id, name, required, maxLength, autoComplete, inputMode, spellCheck, and the other standard input attributes. onSearch/(searchSubmit) handles Enter from the search field; the surrounding form can still be submitted with a separate native submit button.
import { useState } from 'react';
import { Search } from '@udixio/ui-react';
export default function SearchFormReact() {
const [query, setQuery] = useState('');
const [submitted, setSubmitted] = useState('');
const submitQuery = (value: string) => setSubmitted(value);
return (
<form
className="flex w-full flex-col items-center gap-3"
onSubmit={(event) => {
event.preventDefault();
const value = new FormData(event.currentTarget).get('email');
setSubmitted(typeof value === 'string' ? value : '');
}}
>
<Search
id="documentation-email-search"
label="Search by email"
name="email"
query={query}
onQueryChange={setQuery}
onSearch={submitQuery}
placeholder="name@example.com"
inputMode="email"
autoComplete="email"
maxLength={120}
required
spellCheck={false}
/>
<button
type="submit"
className="rounded-full bg-primary px-4 py-2 text-label-large text-on-primary"
>
Submit native form
</button>
<p className="text-body-small text-on-surface-variant" role="status">
{submitted ? `Submitted: ${submitted}` : 'Nothing submitted'}
</p>
</form>
);
}import { ChangeDetectionStrategy, Component } from '@angular/core';
import { Search } from '@udixio/ui-angular';
@Component({
selector: 'docs-search-form-angular',
standalone: true,
imports: [Search],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<form
class="flex w-full flex-col items-center gap-3"
(submit)="handleSubmit($event)"
>
<udx-search
id="documentation-email-search"
label="Search by email"
name="email"
[(query)]="query"
(searchSubmit)="submitted = $event"
placeholder="name@example.com"
inputMode="email"
autoComplete="email"
[maxLength]="120"
required
[spellCheck]="false"
/>
<button
type="submit"
class="rounded-full bg-primary px-4 py-2 text-label-large text-on-primary"
>
Submit native form
</button>
<p class="text-body-small text-on-surface-variant" role="status">
{{ submitted ? 'Submitted: ' + submitted : 'Nothing submitted' }}
</p>
</form>
`,
})
export class SearchFormAngular {
protected query = '';
protected submitted = '';
protected handleSubmit(event: SubmitEvent): void {
event.preventDefault();
const form = event.currentTarget as HTMLFormElement;
const value = new FormData(form).get('email');
this.submitted = typeof value === 'string' ? value : '';
}
}<script lang="ts">
import { Search } from '@udixio/ui-svelte';
let query = $state('');
let submitted = $state('');
</script>
<form
class="flex w-full flex-col items-center gap-3"
onsubmit={(event) => {
event.preventDefault();
const value = new FormData(event.currentTarget).get('email');
submitted = typeof value === 'string' ? value : '';
}}
>
<Search
id="documentation-email-search"
label="Search by email"
name="email"
bind:query
onSearch={(value) => (submitted = value)}
placeholder="name@example.com"
inputMode="email"
autoComplete="email"
maxLength={120}
required
spellCheck={false}
/>
<button type="submit" class="rounded-full bg-primary px-4 py-2 text-label-large text-on-primary">Submit native form</button>
<p class="text-body-small text-on-surface-variant" role="status">{submitted ? `Submitted: ${submitted}` : 'Nothing submitted'}</p>
</form>Auto-focus and focus events
Set autoFocus only when the feature intentionally owns the initial focus, such as after opening a search panel. onFocus/onBlur in React and (focused)/(blurred) in Angular expose native focus transitions for status or surrounding UI updates. When a modal surface is used, let that surface own the panel focus lifecycle and use autoFocus only when it is compatible with that lifecycle. The example mounts the component from a button so the focus behavior can be tested without stealing focus on page load.
Focus events: 0 · Blur events: 0
import { useState } from 'react';
import { Search } from '@udixio/ui-react';
export default function SearchAutofocusReact() {
const [mounted, setMounted] = useState(false);
const [focusCount, setFocusCount] = useState(0);
const [blurCount, setBlurCount] = useState(0);
return (
<div className="flex w-full flex-col items-center gap-3">
<button
type="button"
className="rounded-full bg-primary px-4 py-2 text-label-large text-on-primary"
disabled={mounted}
onClick={() => setMounted(true)}
>
Mount auto-focused search
</button>
{mounted && (
<Search
label="Auto-focused search"
autoFocus
defaultQuery="Material"
onFocus={() => setFocusCount((count) => count + 1)}
onBlur={() => setBlurCount((count) => count + 1)}
/>
)}
<p className="text-body-small text-on-surface-variant" role="status">
Focus events: {focusCount} · Blur events: {blurCount}
</p>
</div>
);
}Focus events: 0 · Blur events: 0
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { Search } from '@udixio/ui-angular';
@Component({
selector: 'docs-search-autofocus-angular',
standalone: true,
imports: [Search],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="flex w-full flex-col items-center gap-3">
<button
type="button"
class="rounded-full bg-primary px-4 py-2 text-label-large text-on-primary"
[disabled]="mounted"
(click)="mounted = true"
>
Mount auto-focused search
</button>
@if (mounted) {
<udx-search
label="Auto-focused search"
[autoFocus]="true"
defaultQuery="Material"
(focused)="focusCount = focusCount + 1"
(blurred)="blurCount = blurCount + 1"
/>
}
<p class="text-body-small text-on-surface-variant" role="status">
Focus events: {{ focusCount }} · Blur events: {{ blurCount }}
</p>
</div>
`,
})
export class SearchAutofocusAngular {
protected mounted = false;
protected focusCount = 0;
protected blurCount = 0;
}Focus events: 0 · Blur events: 0
<script lang="ts">
import { Search } from '@udixio/ui-svelte';
let mounted = $state(false);
let focusCount = $state(0);
let blurCount = $state(0);
</script>
<div class="flex w-full flex-col items-center gap-3">
<button type="button" class="rounded-full bg-primary px-4 py-2 text-label-large text-on-primary" disabled={mounted} onclick={() => (mounted = true)}>
Mount auto-focused search
</button>
{#if mounted}
<Search label="Auto-focused search" autoFocus defaultQuery="Material" onFocus={() => (focusCount += 1)} onBlur={() => (blurCount += 1)} />
{/if}
<p class="text-body-small text-on-surface-variant" role="status">Focus events: {focusCount} · Blur events: {blurCount}</p>
</div>Results and keyboard behavior
Project suggestions or results as children. With the default resultsRole="listbox", give selectable children role="option", aria-selected, and tabIndex={-1} (or tabindex="-1" in Angular). Search shares the menu navigation model: Arrow Down/Up opens the local results surface and moves focus to the first or last option, Enter submits the query, and Escape closes the expanded local results surface and restores focus to the input. A surrounding modal surface handles its own Escape and dismissal behavior.
Set resultsRole="dialog" when the projected content is a richer search view rather than a list of options. In that mode, the consumer owns the dialog content semantics and focus management inside it.
Accessibility
label is required and names the native input[type="search"]. The leading icon is decorative and hidden from assistive technology; interactive actions belong to the clear control or trailing buttons. When results are present, the input exposes the combobox relationship to the labeled results surface. The clear action has a configurable accessible name through clearLabel, and Search keeps the Material 3 48 px touch target for its field actions. If Search is composed inside SideSheet, the side sheet owns the modal dialog semantics, close action, focus trap, and background inertness.
The input forwards native form attributes such as name, required, readOnly, autoComplete, and inputMode. The component does not filter or announce result data itself: applications should update the projected content and provide an appropriate empty state when no match exists.