Search
Search lets someone navigate a product with a query. The search bar and the view that shows its results are one component, which is how Material 3 named them together in 2025.
Usage
A search bar carries a leading search icon, hinted search text and up to two trailing icons you
add. Put the suggestions or results inside as children, in a List; they show once the
field has focus. The view they open floats over the page rather than pushing it down, and closes
again as soon as focus leaves it. Enter reports the query, keeps it visible and moves focus to the
results.
<script lang="ts">
import { List, ListItem, Search } from 'noph-ui'
const dishes = ['Simple Classic Tacos', 'Mexican street corn', 'Chilaquiles verdes']
let query = $state('')
const matches = (q: string) =>
q ? dishes.filter((d) => d.toLowerCase().includes(q.toLowerCase())) : dishes
</script>
<div style="width:26rem;max-width:100%">
<Search bind:value={query} placeholder="Search product">
<List aria-label="Suggestions">
{#each matches(query) as dish (dish)}
<ListItem onclick={() => (query = dish)}>{dish}</ListItem>
{/each}
</List>
</Search>
</div>
Semantics
Suggestions and results are a list, so build them with List and ListItem, and screen readers announce them as one. The container around them brings
no role of its own, since it can also hold category labels, avatars or chips next to the list. The
field stays a plain search field: ↓ moves into the results, the arrow keys walk them,
and ↑ on the first one goes back to the field.
Options that a person steps through while focus stays in the field are a different component: autocomplete is the full combobox, and it
handles the active option and aria-activedescendant for you.
Styles
contained, the default, keeps the field's pill shape and colour while the results
show. Material recommends it. divided squares the field off and separates it from the results
with a rule.
<script lang="ts">
import { List, ListItem, Search } from 'noph-ui'
const dishes = ['Simple Classic Tacos', 'Mexican street corn', 'Chilaquiles verdes']
let dividedQuery = $state('')
const matches = (q: string) =>
q ? dishes.filter((d) => d.toLowerCase().includes(q.toLowerCase())) : dishes
</script>
<div style="width:26rem;max-width:100%">
<Search bind:value={dividedQuery} variant="divided" placeholder="Search product">
<List aria-label="Suggestions">
{#each matches(dividedQuery) as dish (dish)}
<ListItem onclick={() => (dividedQuery = dish)}>{dish}</ListItem>
{/each}
</List>
</Search>
</div>
Trailing icons
The trailing slot takes an avatar or actions. A clear button appears next to it on its
own once there is a query.
<script lang="ts">
import { IconButton, List, ListItem, Search } from 'noph-ui'
import { Icon } from 'noph-ui/icons'
</script>
<div style="width:26rem;max-width:100%">
<Search placeholder="Search product">
{#snippet trailing()}
<IconButton title="Filter"><Icon>settings</Icon></IconButton>
{/snippet}
<List aria-label="Suggestions">
<ListItem type="button">Simple Classic Tacos</ListItem>
</List>
</Search>
</div>
Layout
docked drops the results under the field. The open view is 240px high at the least
and two thirds of the screen at the most. full-screen takes over the viewport once the field has focus. Without a view, search fills the screen in compact windows, below 600px, and docks above them.
<script lang="ts">
import { List, ListItem, Search } from 'noph-ui'
const dishes = ['Simple Classic Tacos', 'Mexican street corn', 'Chilaquiles verdes']
let fullScreenQuery = $state('')
const matches = (q: string) =>
q ? dishes.filter((d) => d.toLowerCase().includes(q.toLowerCase())) : dishes
</script>
<div style="width:26rem;max-width:100%">
<Search bind:value={fullScreenQuery} view="full-screen" placeholder="Search product">
<List aria-label="Suggestions">
{#each matches(fullScreenQuery) as dish (dish)}
<ListItem onclick={() => (fullScreenQuery = dish)}>{dish}</ListItem>
{/each}
</List>
</Search>
</div>
Motion
Material 3 Expressive grows the search bar when it takes focus, by shrinking the margin it keeps to its pane from 24dp to 12dp. The component owns that margin, so the growth needs nothing from you. Change how far it travels with the two custom properties, or set them both to the same value to hold the bar still.
<script lang="ts">
import { List, ListItem, Search } from 'noph-ui'
</script>
<div style="width:26rem;max-width:100%">
<Search
placeholder="Search product"
--np-search-pane-margin="4rem"
--np-search-view-margin="0rem"
>
<List aria-label="Suggestions">
<ListItem type="button">Simple Classic Tacos</ListItem>
</List>
</Search>
</div>
Search app bar
Use a search app bar as an emphasised, global entry point. It is the app bar's search variant, which carries
a field in place of a headline.
<AppBar variant="search">
{#snippet leading()}
<IconButton title="Open navigation"><Icon>menu</Icon></IconButton>
{/snippet}
{#snippet search()}
<Search bind:value={query} placeholder="Search product" />
{/snippet}
</AppBar>Opening it yourself
Focusing the field opens the view, so most of the time nothing is needed from you. Where the bar
has no room to sit at rest, a narrow app bar say, a trigger elsewhere can open it through show, and close puts it away again. Both take care of the caret, and show puts the bar in the document before it moves focus there, so a bar that is
hidden until it opens still works. It does that synchronously, which is what lets Safari on iOS
open the keyboard: the tap that called show is still on the stack.
<script lang="ts">
import { IconButton, List, ListItem, Search } from 'noph-ui'
import { Icon } from 'noph-ui/icons'
let search: ReturnType<typeof Search> | undefined = $state()
let expanded = $state(false)
</script>
<IconButton title="Open search" onclick={() => search?.show()}>
<Icon>search</Icon>
</IconButton>
<!-- The bar has nowhere to sit until it opens, so it is out of the layout while at rest. -->
<div class={['view', !expanded && 'resting']}>
<Search bind:this={search} bind:expanded view="full-screen" placeholder="Search product">
<List aria-label="Suggestions">
<ListItem type="button">Simple Classic Tacos</ListItem>
</List>
</Search>
</div>
<style>
.view.resting {
display: none;
}
</style>
Theming
| Custom property | Description |
|---|---|
--np-search-container-color | Background of the field and results. |
--np-search-view-background-color | Background behind a full screen contained view. |
--np-search-shape | Corner radius of the field. |
--np-search-width | Widest the field grows. 720dp. |
--np-search-pane-margin | Space the field keeps to its pane at rest. 24dp. |
--np-search-view-margin | The same space once the view is open. 12dp. |
--np-search-results-shape | Corner radius of the docked results. |
--np-search-docked-min-height | Least height of the open docked view, field included. 240dp. |
--np-search-docked-max-height | Most height of the open docked view. Two thirds of the screen. |
--np-search-results-max-height | How tall the docked results grow. |
--np-search-z-index | How the open view stacks over the page. |
--np-search-divider-color | The rule under a divided field. |
--np-search-focus-indicator-color | The focus ring. |
Accessibility
The field is a native <input type="search"> named by its hinted text, the placeholder, or by label where that should differ. The clear button and
the back arrow are named by clearLabel and backLabel, so all of them can
be translated. Whenever results appear or change, a polite status tells screen readers how many
there are; resultsAnnouncement words it.
Focus opens the view and moving focus out of it closes it again, which keeps the keyboard and the pointer in step. Escape closes it from the field or the results and leaves the query alone. The leading and trailing actions keep to themselves: clicking one does not open the view. See Semantics for the keyboard in the results.
API
Renders a <div> holding the field and its results, and takes its attributes. bind:element gives you that wrapper and bind:inputElement the input.
Methods
| Method | Description |
|---|---|
show() | Opens the view and moves focus into the field, both before it returns. |
close() | Closes the view and takes focus off the field. |
Attributes
| Attribute | Type | Default | Description |
|---|---|---|---|
value | string | '' | Bindable. The query. |
placeholder | string | '' | Hinted search text. |
expanded | boolean | false | Bindable. Whether the results are showing. Focusing the field opens it. |
variant | 'contained' | 'divided' | 'contained' | Whether the field keeps its pill while the results show. |
view | 'docked' | 'full-screen' | undefined | undefined | Results under the field, or covering the viewport. Without one, full screen below 600px and docked above. |
leading | Snippet | undefined | undefined | Replaces the search icon, and the back arrow shown once expanded. |
trailing | Snippet | undefined | undefined | Avatar or actions, two at most. The clear button sits beside it in the open view. |
onsearch | (value: string) => void | undefined | Called on the Enter key with the current query. |
label | string | placeholder | Accessible name for the field, the hinted text unless you set one. |
resultsAnnouncement | (count: number) => string | '3 results' | What screen readers hear when results appear or change. |
clearLabel | string | 'Clear search' | Accessible name for the clear button. |
backLabel | string | 'Close search' | Accessible name for the back action shown while expanded. |
inputAttributes | HTMLInputAttributes | undefined | undefined | Extra attributes for the underlying input. |
resultsAttributes | HTMLAttributes<HTMLDivElement> | undefined | undefined | Extra attributes for the results container, and where its role goes. |