Skip to content

Navigation

Search

A search bar that expands into a full-screen or docked view of suggestions and results.

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.

React HTML
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.

React HTML
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

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

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-screen default | 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 (default search), 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 native select does not reach the host). The native input event 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

@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.