Navigation
Search
A search bar that expands into a full-screen or docked view of suggestions and results.
- COMPONENT_SPEC §12
- CSS · custom elements · React
- Open in the playground
Examples
Variants
Focus a bar to expand it: full screen, full screen contained, docked and docked with a gap. The suggestions are a listbox the arrow keys move through while focus stays in the input; Escape or the back button collapses the view.
The live demo runs with JavaScript. Its code is below.
import { useState } from 'react';
import { SearchBar, type SearchBarVariant, SearchResult } from '@kyralabs/m3exp-web/react';
const FRUITS = ['Apple', 'Apricot', 'Banana', 'Blueberry', 'Cherry', 'Grape', 'Kiwi', 'Mango'];
function FruitSearch({ variant }: { variant: SearchBarVariant }) {
const [query, setQuery] = useState('');
const matches = FRUITS.filter((f) => f.toLowerCase().includes(query.trim().toLowerCase()));
return (
<SearchBar
variant={variant}
aria-label={`Search fruit (${variant})`}
placeholder={`Search fruit (${variant})`}
value={query}
onChange={setQuery}
onSelect={(value) => {
console.log('selected', value);
}}
>
{matches.map((fruit) => (
<SearchResult key={fruit} value={fruit.toLowerCase()} icon="history">
{fruit}
</SearchResult>
))}
</SearchBar>
);
}
export default function Variants() {
return (
<div style={{ display: 'grid', gap: 'var(--m3e-sys-space-200)', inlineSize: '100%' }}>
<FruitSearch variant="full-screen" />
<FruitSearch variant="full-screen-contained" />
<FruitSearch variant="docked" />
<FruitSearch variant="docked-with-gap" />
</div>
);
}
<div style="display: grid; gap: var(--m3e-sys-space-200); inline-size: 100%">
<m3e-search
variant="full-screen"
label="Search fruit (full-screen)"
placeholder="Search fruit (full-screen)"
>
<m3e-search-result value="apple" icon="history">Apple</m3e-search-result>
<m3e-search-result value="apricot" icon="history">Apricot</m3e-search-result>
<m3e-search-result value="banana" icon="history">Banana</m3e-search-result>
<m3e-search-result value="blueberry" icon="history">Blueberry</m3e-search-result>
<m3e-search-result value="cherry" icon="history">Cherry</m3e-search-result>
<m3e-search-result value="grape" icon="history">Grape</m3e-search-result>
<m3e-search-result value="kiwi" icon="history">Kiwi</m3e-search-result>
<m3e-search-result value="mango" icon="history">Mango</m3e-search-result>
</m3e-search>
<m3e-search
variant="full-screen-contained"
label="Search fruit (full-screen-contained)"
placeholder="Search fruit (full-screen-contained)"
>
<m3e-search-result value="apple" icon="history">Apple</m3e-search-result>
<m3e-search-result value="apricot" icon="history">Apricot</m3e-search-result>
<m3e-search-result value="banana" icon="history">Banana</m3e-search-result>
<m3e-search-result value="blueberry" icon="history">Blueberry</m3e-search-result>
<m3e-search-result value="cherry" icon="history">Cherry</m3e-search-result>
<m3e-search-result value="grape" icon="history">Grape</m3e-search-result>
<m3e-search-result value="kiwi" icon="history">Kiwi</m3e-search-result>
<m3e-search-result value="mango" icon="history">Mango</m3e-search-result>
</m3e-search>
<m3e-search variant="docked" label="Search fruit (docked)" placeholder="Search fruit (docked)">
<m3e-search-result value="apple" icon="history">Apple</m3e-search-result>
<m3e-search-result value="apricot" icon="history">Apricot</m3e-search-result>
<m3e-search-result value="banana" icon="history">Banana</m3e-search-result>
<m3e-search-result value="blueberry" icon="history">Blueberry</m3e-search-result>
<m3e-search-result value="cherry" icon="history">Cherry</m3e-search-result>
<m3e-search-result value="grape" icon="history">Grape</m3e-search-result>
<m3e-search-result value="kiwi" icon="history">Kiwi</m3e-search-result>
<m3e-search-result value="mango" icon="history">Mango</m3e-search-result>
</m3e-search>
<m3e-search
variant="docked-with-gap"
label="Search fruit (docked-with-gap)"
placeholder="Search fruit (docked-with-gap)"
>
<m3e-search-result value="apple" icon="history">Apple</m3e-search-result>
<m3e-search-result value="apricot" icon="history">Apricot</m3e-search-result>
<m3e-search-result value="banana" icon="history">Banana</m3e-search-result>
<m3e-search-result value="blueberry" icon="history">Blueberry</m3e-search-result>
<m3e-search-result value="cherry" icon="history">Cherry</m3e-search-result>
<m3e-search-result value="grape" icon="history">Grape</m3e-search-result>
<m3e-search-result value="kiwi" icon="history">Kiwi</m3e-search-result>
<m3e-search-result value="mango" icon="history">Mango</m3e-search-result>
</m3e-search>
</div>
<script>
// Filter the suggestions as the query changes: the search renders its result children.
for (const search of document.querySelectorAll('m3e-search')) {
const all = [...search.querySelectorAll('m3e-search-result')];
search.addEventListener('input', (event) => {
if (!(event.target instanceof HTMLInputElement)) return;
const query = event.target.value.trim().toLowerCase();
for (const result of all) result.remove();
search.append(...all.filter((r) => r.textContent.toLowerCase().includes(query)));
});
search.addEventListener('select', (event) => {
console.log('selected', event.detail.value);
});
}
</script>
Run the HTML version (custom elements, no framework).
In a top app bar
An app bar with a search bar between the navigation icon and the actions, entering again on any scroll up.
The live demo runs with JavaScript. Its code is below.
import { useRef, useState } from 'react';
import {
AppBarWithSearch,
Icon,
IconButton,
SearchBar,
SearchResult,
} from '@kyralabs/m3exp-web/react';
const RECENT = ['Hiking trails', 'Kayak rental', 'Mountain huts'];
const ROWS = Array.from({ length: 20 }, (_, i) => `Trip ${String(i + 1)}`);
export default function AppBar() {
const scroller = useRef<HTMLDivElement>(null);
const [query, setQuery] = useState('');
return (
<div
ref={scroller}
tabIndex={0}
role="region"
aria-label="Trips, scrollable"
style={{
overflow: 'auto',
inlineSize: '100%',
maxInlineSize: 'var(--m3e-sys-window-width-medium)',
blockSize: 'var(--m3e-sys-window-height-medium)',
}}
>
<AppBarWithSearch
scrollBehavior="enter-always"
scrollTarget={scroller}
style={{ position: 'sticky', insetBlockStart: 0, zIndex: 1 }}
navigationIcon={
<IconButton aria-label="Menu">
<Icon name="menu" />
</IconButton>
}
actions={
<IconButton aria-label="Account">
<Icon name="account_circle" />
</IconButton>
}
>
<SearchBar
variant="docked-with-gap"
aria-label="Search trips"
placeholder="Search trips"
value={query}
onChange={setQuery}
>
{RECENT.filter((r) => r.toLowerCase().includes(query.trim().toLowerCase())).map((r) => (
<SearchResult key={r} value={r} icon="history">
{r}
</SearchResult>
))}
</SearchBar>
</AppBarWithSearch>
<ul style={{ margin: 0, padding: 'var(--m3e-sys-space-200)' }}>
{ROWS.map((row) => (
<li key={row} style={{ paddingBlock: 'var(--m3e-sys-space-150)' }}>
{row}
</li>
))}
</ul>
</div>
);
}
<div
id="trips"
tabindex="0"
role="region"
aria-label="Trips, scrollable"
style="
overflow: auto;
inline-size: 100%;
max-inline-size: var(--m3e-sys-window-width-medium);
block-size: var(--m3e-sys-window-height-medium);
"
>
<header
class="m3e-top-app-bar m3e-top-app-bar--search"
style="position: sticky; inset-block-start: 0; z-index: 1"
>
<div class="m3e-top-app-bar__navigation">
<button type="button" class="m3e-icon-button m3e-icon-button--standard" aria-label="Menu">
<span class="m3e-icon" aria-hidden="true">menu</span>
</button>
</div>
<m3e-search variant="docked-with-gap" label="Search trips" placeholder="Search trips">
<m3e-search-result icon="history">Hiking trails</m3e-search-result>
<m3e-search-result icon="history">Kayak rental</m3e-search-result>
<m3e-search-result icon="history">Mountain huts</m3e-search-result>
</m3e-search>
<div class="m3e-top-app-bar__actions">
<button type="button" class="m3e-icon-button m3e-icon-button--standard" aria-label="Account">
<span class="m3e-icon" aria-hidden="true">account_circle</span>
</button>
</div>
</header>
<ul style="margin: 0; padding: var(--m3e-sys-space-200)">
<li style="padding-block: var(--m3e-sys-space-150)">Trip 1</li>
<li style="padding-block: var(--m3e-sys-space-150)">Trip 2</li>
<li style="padding-block: var(--m3e-sys-space-150)">Trip 3</li>
<li style="padding-block: var(--m3e-sys-space-150)">Trip 4</li>
<li style="padding-block: var(--m3e-sys-space-150)">Trip 5</li>
<li style="padding-block: var(--m3e-sys-space-150)">Trip 6</li>
<li style="padding-block: var(--m3e-sys-space-150)">Trip 7</li>
<li style="padding-block: var(--m3e-sys-space-150)">Trip 8</li>
<li style="padding-block: var(--m3e-sys-space-150)">Trip 9</li>
<li style="padding-block: var(--m3e-sys-space-150)">Trip 10</li>
<li style="padding-block: var(--m3e-sys-space-150)">Trip 11</li>
<li style="padding-block: var(--m3e-sys-space-150)">Trip 12</li>
<li style="padding-block: var(--m3e-sys-space-150)">Trip 13</li>
<li style="padding-block: var(--m3e-sys-space-150)">Trip 14</li>
<li style="padding-block: var(--m3e-sys-space-150)">Trip 15</li>
<li style="padding-block: var(--m3e-sys-space-150)">Trip 16</li>
<li style="padding-block: var(--m3e-sys-space-150)">Trip 17</li>
<li style="padding-block: var(--m3e-sys-space-150)">Trip 18</li>
<li style="padding-block: var(--m3e-sys-space-150)">Trip 19</li>
<li style="padding-block: var(--m3e-sys-space-150)">Trip 20</li>
</ul>
</div>
<script type="module">
import { attachTopAppBarScroll } from '@kyralabs/m3e-elements/top-app-bar.js';
const scroller = document.getElementById('trips');
attachTopAppBarScroll(scroller.querySelector('.m3e-top-app-bar'), {
behavior: 'enter-always',
target: scroller,
signal: new AbortController().signal,
});
</script>
Run the HTML version (custom elements, no framework).
API
React
<SearchBar>
Search (COMPONENT_SPEC §12; Compose SearchBar + SearchBarDefaults.InputField with one of the Expanded*SearchBar views): the .m3e-search contract, role="search" around a combobox input and a listbox of <SearchResult>s. On the web the collapsed bar and its expanded view are one element (the view moves into the top layer), so one component takes a variant. Behavior comes from m3e-elements' SearchLayout, the same code <m3e-search> runs: expand / collapse on the variant's springs, the combobox (virtual focus, as React Aria's useComboBox), light dismiss and the full-screen focus trap (React Aria's useComboBox state closes on blur and selection, which Compose's expanded view does not).
| Prop | Type | Default | Description |
|---|---|---|---|
variant
|
"full-screen" | "full-screen-contained" | "docked" | "docked-with-gap"
|
full-screen |
Default full-screen.
|
value
|
string
|
The query (controlled). | |
defaultValue
|
string
|
The initial query (uncontrolled). | |
isExpanded
|
boolean
|
Controlled expanded state (SearchBarState.targetValue). | |
defaultExpanded
|
boolean
|
false |
Uncontrolled initial state. Default false.
|
aria-label
|
string
|
The input's accessible name. Default "Search" (Compose Strings.SearchBarSearch). | |
resultsLabel
|
string
|
The results listbox's accessible name. Default "Suggestions". | |
leadingIcon
|
string | ReactElement
|
search |
Collapsed leading icon: a Material Symbols name or an <Icon>. Default search.
|
backLabel
|
string
|
The expanded view's back button label. Default "Back". | |
trailing
|
ReactNode
|
Trailing content: <IconButton>s, an <img className="m3e-search__avatar">.
|
|
children
|
ReactNode
|
<SearchResult>s.
|
|
placeholder
|
string
|
||
isDisabled
|
boolean
|
||
onSearch
|
(value: string) => unknown
|
Enter without an active result. Return false to stay expanded (default: collapse).
|
|
onSelect
|
(value: string, label: string) => unknown
|
A result was activated. Return false to stay expanded and keep the query; by default the
query becomes the result's label and the view collapses (the Compose samples).
|
|
onChange
|
(value: string) => void
|
||
onExpandedChange
|
(expanded: boolean) => void
|
||
expanded
|
boolean
|
Deprecated:
Since 1.8: use isExpanded. Removed in 2.0.
Controlled expanded state.
|
Also accepts HTML attributes not listed here.
<SearchResult>
One result of a <SearchBar> (the rows of the Compose samples' SampleSearchResults): a role="option" of the results listbox. Selected with a click, or ArrowDown / ArrowUp and Enter from the input (virtual focus).
| Prop | Type | Default | Description |
|---|---|---|---|
value
|
string
|
Passed to onSelect. Default: the label text.
|
|
children
required
|
ReactNode
|
The label (ListItem content). | |
textValue
|
string
|
The label as plain text, when children is not a string (what the input takes on select).
|
|
icon
|
string | ReactElement
|
Leading icon: a Material Symbols name or an <Icon>.
|
|
supportingText
|
ReactNode
|
Second line (bodyMedium). | |
trailingIcon
|
string | ReactElement
|
Trailing icon: a Material Symbols name or an <Icon>.
|
|
isDisabled
|
boolean
|
<AppBarWithSearch>
AppBarWithSearch (COMPONENT_SPEC §12; Compose AppBarWithSearch): a top app bar (.m3e-top-app-bar--search) holding a <SearchBar> between the navigation icon and the actions. The scroll behavior is the top app bar's (m3e-elements attachTopAppBarScroll): its enter-always math, overlap and settle are the same as Compose's search bar scroll behavior.
| Prop | Type | Default | Description |
|---|---|---|---|
children
required
|
ReactNode
|
The <SearchBar>.
|
|
navigationIcon
|
ReactNode
|
Usually one <IconButton>.
|
|
actions
|
ReactNode
|
Usually <IconButton>s.
|
|
scrollBehavior
|
'enter-always'
|
enter-always (SearchBarDefaults.enterAlwaysSearchBarScrollBehavior): hides on scroll down,
returns on any scroll up and takes the scrolled colors. Default: none (static colors).
|
|
scrollTarget
|
RefObject<HTMLElement | null>
|
The scrolling element the bar sits over. Default: the document. |
Also accepts HTML attributes not listed here.
Custom elements
<m3e-search>
import '@kyralabs/m3e-elements/search.js';
<m3e-search variant="docked" placeholder="Search"> (COMPONENT_SPEC §12; Compose SearchBar + SearchBarDefaults.InputField with ExpandedFullScreenSearchBar / ExpandedFullScreenContainedSearchBar / ExpandedDockedSearchBar / ExpandedDockedSearchBarWithGap). Renders the m3e-css .m3e-search contract inside itself (the host is display: contents): role="search" around a combobox input and a listbox of its <m3e-search-result> children. Other children (icon buttons, an img.m3e-search__avatar) go to the trailing slot. Behavior: m3e-elements SearchLayout (shared with m3e-react).
- Attributes:
variant(full-screendefault |full-screen-contained|docked|docked-with-gap),placeholder,value,label(the input's accessible name, default "Search", Compose Strings.SearchBarSearch),results-label(the listbox's, default "Suggestions"),leading-icon(defaultsearch),back-label(the expanded view's back button, default "Back"),expanded(reflected),disabled. - Events:
expanded-change(detail: { expanded });search(detail: { value }, Enter without an active result; cancelable:preventDefault()keeps the view expanded);select(detail: { value, label }, a result activated; cancelable: by default the input takes the label and the view collapses, as the Compose samples do; the input's nativeselectdoes not reach the host). The nativeinputevent bubbles. expand()/collapse().
| Attribute | Property | Type | Notes |
|---|---|---|---|
variant
|
variant
|
string
|
|
placeholder
|
placeholder
|
string
|
|
value
|
value
|
string
|
|
label
|
label
|
string
|
|
results-label
|
resultsLabel
|
string
|
|
leading-icon
|
leadingIcon
|
string
|
|
back-label
|
backLabel
|
string
|
|
expanded
|
expanded
|
boolean
|
Reflected. |
disabled
|
disabled
|
boolean
|
| Event | Detail | Description |
|---|---|---|
expanded-change
|
ExpandedChangeDetail
|
Expanded or collapsed. |
search
|
SearchDetail
|
Enter without an active result; cancelable (preventDefault() keeps the view expanded).
|
select
|
SearchSelectDetail
|
A result was activated; cancelable. The input's native select never reaches the host.
|
<m3e-search-result>
import '@kyralabs/m3e-elements/search.js';
<m3e-search-result value="apple" icon="history" supporting-text="Fruit">Apple</m3e-search-result> — data for one result of a <m3e-search> (it renders nothing itself; the search renders .m3e-search__result): value (default: the label), icon / trailing-icon (Material Symbols), supporting-text, disabled. The label is the text content.
| Attribute | Property | Type | Notes |
|---|---|---|---|
value
|
value
|
string
|
|
icon
|
icon
|
string
|
|
trailing-icon
|
trailingIcon
|
string
|
|
supporting-text
|
supportingText
|
string
|
|
disabled
|
disabled
|
boolean
|
CSS
search.css
@import '@kyralabs/m3e-css/search.css';
Search (COMPONENT_SPEC §12): the search bar (Compose SearchBar + SearchBarDefaults.InputField), its expanded views — full-screen (ExpandedFullScreenSearchBar, the default), full-screen contained (ExpandedFullScreenContainedSearchBar, --full-screen-contained), docked (ExpandedDockedSearchBar, --docked) and docked with gap (ExpandedDockedSearchBarWithGap, --docked-with-gap) — and the search bar inside a top app bar (AppBarWithSearch, .m3e-top-app-bar--search).
<div class="m3e-search m3e-search--docked" role="search">
<div class="m3e-search__view">
<div class="m3e-search__field">
<span class="m3e-search__leading"><span class="m3e-icon" aria-hidden="true">search</span></span>
<input class="m3e-search__input" role="combobox" aria-label="Search" placeholder="Search"…>
<span class="m3e-search__trailing">…icon buttons, an .m3e-search__avatar…</span>
</div>
<div class="m3e-search__results" role="listbox" aria-label="Suggestions">
<div class="m3e-search__result" role="option" aria-selected="false">
<span class="m3e-search__result-leading"><span class="m3e-icon">history</span></span>
<span class="m3e-search__result-text">
<span class="m3e-search__result-label">Label</span>
<span class="m3e-search__result-supporting">Supporting text</span>
</span>
<span class="m3e-search__result-trailing">…</span>
</div>
</div>
</div>
</div>
<header class="m3e-top-app-bar m3e-top-app-bar--search">
<div class="m3e-top-app-bar__navigation">…</div>
<div class="m3e-search" role="search">…</div>
<div class="m3e-top-app-bar__actions">…</div>
</header>
Without JavaScript this is the collapsed bar. The expanded views need m3e-elements (<m3e-search>, or SearchLayout) or m3e-react (<SearchBar>): they set data-expanded (target), data-open (the view is shown: from the start of an expansion to the end of the collapse) and put the view in the top layer (popover="manual"), writing each frame's geometry (m3e-motion searchViewFrame) as viewport px: --_m3e-search-surface-{x,y,width,height,radius,opacity, min-height,max-height}, --_m3e-search-field-{x,y,width}, --_m3e-search-results-{x,y,width, max-height,opacity} and, docked with gap, --_m3e-search-slide with data-dropdown.
70 custom properties it reads
| Custom property | Default |
|---|---|
--m3e-comp-divider-thickness
|
1px |
--m3e-comp-filled-text-field-caret-color
|
var(--m3e-sys-color-primary) |
--m3e-comp-filled-text-field-input-color-disabled
|
var(--m3e-sys-color-on-surface) |
--m3e-comp-filled-text-field-input-opacity-disabled
|
0.38 |
--m3e-comp-filled-text-field-leading-icon-color-disabled
|
var(--m3e-sys-color-on-surface) |
--m3e-comp-filled-text-field-leading-icon-opacity-disabled
|
0.38 |
--m3e-comp-filled-text-field-trailing-icon-color-disabled
|
var(--m3e-sys-color-on-surface) |
--m3e-comp-filled-text-field-trailing-icon-opacity-disabled
|
0.38 |
--m3e-comp-list-item-between-space
|
12px |
--m3e-comp-list-item-bottom-space
|
10px |
--m3e-comp-list-item-label-text-color
|
var(--m3e-sys-color-on-surface) |
--m3e-comp-list-item-label-text-font
|
var(--m3e-sys-typescale-body-large-font) |
--m3e-comp-list-item-label-text-line-height
|
var(--m3e-sys-typescale-body-large-line-height) |
--m3e-comp-list-item-label-text-opacity-disabled
|
0.38 |
--m3e-comp-list-item-label-text-size
|
var(--m3e-sys-typescale-body-large-size) |
--m3e-comp-list-item-label-text-tracking
|
var(--m3e-sys-typescale-body-large-tracking) |
--m3e-comp-list-item-label-text-weight
|
var(--m3e-sys-typescale-body-large-weight) |
--m3e-comp-list-item-leading-icon-color
|
var(--m3e-sys-color-on-surface-variant) |
--m3e-comp-list-item-leading-icon-size
|
24px |
--m3e-comp-list-item-leading-space
|
16px |
--m3e-comp-list-item-one-line-container-height
|
56px |
--m3e-comp-list-item-supporting-text-color
|
var(--m3e-sys-color-on-surface-variant) |
--m3e-comp-list-item-supporting-text-font
|
var(--m3e-sys-typescale-body-medium-font) |
--m3e-comp-list-item-supporting-text-line-height
|
var(--m3e-sys-typescale-body-medium-line-height) |
--m3e-comp-list-item-supporting-text-size
|
var(--m3e-sys-typescale-body-medium-size) |
--m3e-comp-list-item-supporting-text-tracking
|
var(--m3e-sys-typescale-body-medium-tracking) |
--m3e-comp-list-item-supporting-text-weight
|
var(--m3e-sys-typescale-body-medium-weight) |
--m3e-comp-list-item-top-space
|
10px |
--m3e-comp-list-item-trailing-icon-color
|
var(--m3e-sys-color-on-surface-variant) |
--m3e-comp-list-item-trailing-icon-size
|
24px |
--m3e-comp-list-item-trailing-space
|
16px |
--m3e-comp-list-item-two-line-container-height
|
72px |
--m3e-comp-minimum-interactive-component-size
|
48px |
--m3e-comp-search-app-bar-color-duration
|
var(--m3e-sys-motion-spring-default-effects-duration) |
--m3e-comp-search-app-bar-color-easing
|
var(--m3e-sys-motion-spring-default-effects-easing) |
--m3e-comp-search-app-bar-horizontal-padding
|
4px |
--m3e-comp-search-app-bar-search-padding
|
8px |
--m3e-comp-search-app-bar-snap-damping-ratio
|
var(--m3e-sys-motion-spring-default-effects-damping-ratio) |
--m3e-comp-search-app-bar-snap-duration
|
var(--m3e-sys-motion-spring-default-effects-duration) |
--m3e-comp-search-app-bar-snap-stiffness
|
var(--m3e-sys-motion-spring-default-effects-stiffness) |
--m3e-comp-search-app-bar-vertical-padding
|
4px |
--m3e-comp-search-bar-avatar-shape
|
var(--m3e-sys-shape-corner-full) |
--m3e-comp-search-bar-avatar-size
|
30px |
--m3e-comp-search-bar-container-color
|
var(--m3e-sys-color-surface-container-high) |
--m3e-comp-search-bar-container-height
|
56px |
--m3e-comp-search-bar-container-shape
|
var(--m3e-sys-shape-corner-full) |
--m3e-comp-search-bar-input-text-color
|
var(--m3e-sys-color-on-surface) |
--m3e-comp-search-bar-input-text-font
|
var(--m3e-sys-typescale-body-large-font) |
--m3e-comp-search-bar-input-text-line-height
|
var(--m3e-sys-typescale-body-large-line-height) |
--m3e-comp-search-bar-input-text-size
|
var(--m3e-sys-typescale-body-large-size) |
--m3e-comp-search-bar-input-text-tracking
|
var(--m3e-sys-typescale-body-large-tracking) |
--m3e-comp-search-bar-input-text-weight
|
var(--m3e-sys-typescale-body-large-weight) |
--m3e-comp-search-bar-leading-icon-color
|
var(--m3e-sys-color-on-surface) |
--m3e-comp-search-bar-supporting-text-color
|
var(--m3e-sys-color-on-surface-variant) |
--m3e-comp-search-bar-trailing-icon-color
|
var(--m3e-sys-color-on-surface-variant) |
--m3e-comp-search-contained-container-color
|
var(--m3e-sys-color-surface-container-low) |
--m3e-comp-search-dropdown-corner-radius
|
12px |
--m3e-comp-search-icon-offset
|
4px |
--m3e-comp-search-input-icon-padding
|
12px |
--m3e-comp-search-input-padding
|
16px |
--m3e-comp-search-max-width
|
720px |
--m3e-comp-search-min-width
|
360px |
--m3e-comp-search-result-padding-horizontal
|
16px |
--m3e-comp-search-result-padding-vertical
|
4px |
--m3e-comp-search-scrim-color
|
var(--m3e-sys-color-scrim) |
--m3e-comp-search-scrim-opacity
|
0.32 |
--m3e-comp-search-scrolled-container-color
|
var(--m3e-sys-color-surface-container-highest) |
--m3e-comp-search-shadow
|
var(--m3e-sys-elevation-level0-shadow) |
--m3e-comp-search-view-divider-color
|
var(--m3e-sys-color-outline) |
--m3e-comp-search-view-docked-container-shape
|
var(--m3e-sys-shape-corner-extra-large) |
Accessibility
role="search" landmark (named by the label: landmark names must be unique) around an ARIA combobox (React Aria's / APG list-autocomplete model, virtual focus): input role="combobox", aria-autocomplete="list", aria-expanded, aria-controls (expanded), aria-activedescendant; results role="listbox" (not a tab stop) of role="option" rows (aria-selected on the active one, aria-disabled). ArrowDown / ArrowUp move the active option (no wrap; ArrowUp from the first returns to the input; disabled rows skipped), Enter selects it or searches. Full-screen views are role="dialog" aria-modal="true" and trap Tab. Default labels: "Search" (Compose m3c_search_bar_search), listbox "Suggestions", back button "Back".
a click on the field (not on its buttons), typing or ArrowDown expands (Compose non-touch mode: focus alone does not); expanding focuses the input. Escape, the back button, a light dismiss (docked: a pointer down outside the panel or focus leaving it; with gap: the scrim), a search (Enter) or a selection collapse it; focus stays in (or returns to) the input. The view moves into the top layer (popover="manual"), so app bar clipping and transforms do not cut it; full-screen views lock the document scroll while open (Compose shows them in a dialog). Selecting a result puts its label in the input (the samples).
Every interactive part has a target of at least 48 × 48 px, a visible focus ring for
keyboard focus only, and works under forced-colors and
prefers-reduced-motion.
Differences from Compose
no predictive back; no touch-mode distinction (Compose expands on focus and clears focus on collapse in touch mode; here a tap is a click, focus stays); the input field is 360dp wide by default (Compose grows it with a placeholder longer than that); the focus ring shows whenever the input is :focus-visible (text inputs always are; Compose only in non-touch mode); no IME-specific behavior (the keyboard hide on collapse is the browser's); the docked views follow the bar on scroll and resize; with-gap slide offsets are not rounded to whole pixels; SearchBarColors / AppBarWithSearchColors customization is by overriding the tokens; result rows are a minimal ListItem until the List component lands (M8).
Sources
Values come from androidx Compose Material3 1.5.0-beta01 at 19991f2c and from
m3.material.io, as cited below from COMPONENT_SPEC §12 and the m3e-css sources.
SearchBar.kt (vendored), SearchBarTokens.kt, SearchViewTokens.kt, internal/TextFieldImpl.kt, ListItem.kt, ListItemDefaults.kt, ComposeMaterial3Flags.kt, ListTokens.kt, AppBarTokens.kt, SearchBarSamples.kt (vendored).
SearchBarTokens.kt, SearchViewTokens.kt@19991f2c plus the values Compose renders (components/search-defaults.css; SOURCES register #30). Supersedes the earlier TODO(verify).
search.css sources
(compose/material3 @ 19991f2c, SearchBar.kt; values in search-defaults.css, search-bar.css, search-view.css, list.css):
- Bar: Surface(inputFieldShape = CornerFull, SearchBarTokens.ContainerColor, Level0 elevations) around InputField: 360–720dp wide (sizeIn; here 360dp, narrower when the space is), at least InputFieldHeight (56dp) tall, bodyLarge. TextField layout: 48dp icon slots at the edges, the leading one moved in and the trailing one out by SearchBarIconOffsetX (4dp); the text padded by TextFieldPadding (16dp), minus the 12dp icon padding beside an icon. Colors: inputFieldColors. Focus: the ripple's inset focus ring on the input field shape (non-touch mode).
- Full-screen: the surface grows from the bar to the window and its corner radius from InputFieldHeight / 2 to 0; the field moves to 8dp under the top inset and widens to the window, followed by 8dp, the divider (SearchViewTokens.DividerColor) and the results, faded in by the progress. Contained: the window surface (SurfaceContainerLow) fades in; the field keeps its pill and container color, padded 8dp at the sides and 4dp at the top; the results (no divider) fade in on the content tween. Docked: one panel (SearchViewTokens.DockedContainerShape) at the bar, growing to fit the results, at least 240dp and at most 2/3 of the window. Docked with gap: a scrim fades in; the results sit 2dp under the field in their own 12dp-rounded box (at most 1/2 of the window), sliding in and fading in.
- Results: rows of the SearchBarSamples ListItem (ListTokens: 16dp start / end and 10dp top / bottom space, 12dp between the leading icon and the text, 56 / 72dp one / two-line heights) padded by the samples' 16 / 4dp inside the clickable row; transparent container.
- AppBarWithSearch: AppBarTokens container (on-scroll container when content is scrolled under it, on the default effects spring), 4dp before the navigation icon and after the actions, the search bar centered with 8dp beside and 4dp above and below it; its container switches to SurfaceContainerHighest when scrolled (not animated). Enter-always moves the whole content by the offset (placeWithLayer(0, scrollOffset)) and clips it.