Find

A find-in-view card with a field, a match count and stepping controls, and an optional list of results beneath them.

Installation

import { Find } from "@o/pipeline/experimental/components/find";

Default

Type in the field to search the messages below it. ⏎ and ⇧⏎ step through the matches, and ↓ opens the list of messages that contain one.

1 of 4
  1. MCMaya Chen · 9:12Northwind want the pricing sheet before Thursday's call.
  2. OOperate · 9:14I drafted a pricing summary from the last three calls with Northwind.
  3. SOSam Ortiz · 9:31Looks good. Can we move the security review to Friday?
  4. MCMaya Chen · 9:40Friday works. I'll send Priya the updated pricing tiers tonight.
  5. SOSam Ortiz · 10:05 · in Northwind procurementTheir procurement team asked whether pricing is per seat or per workspace.
  6. OOperate · 10:06 · in Northwind procurementPer seat, with a workspace minimum of ten seats.
  7. MCMaya Chen · 11:20 · in Security reviewSecurity questionnaire is back, nothing blocking.

Usage

Find is a compound set of parts built on Base UI Autocomplete, rendered inline and always open, so the results list sits inside the card rather than in a popup of its own. It doesn’t search anything itself: you own the query, the matches, which match is current, and whether the list is shown.

  • Query: controlled through Root’s value and onValueChange. Choosing a row never rewrites the query.
  • Stepping: Root’s onStep(direction) receives "next" or "previous" from the keyboard and from Previous and Next. Move your current match and scroll it into view.
  • Results: pass items as groups ({ value, items }), already filtered to the rows that match. Drop a group with no rows, or its label renders on its own.
  • List: listOpen and onListOpenChange decide whether the list is shown. The field asks to open it on ↓ or ↑, when items has a row to show, and to close it on Esc.
  • Closing: onClose fires on Esc with the list closed and from Close. Unmount the card in response.
  • Warm on intent: onItemHighlighted fires as a row is highlighted by keyboard or pointer, to preview its match before it’s chosen.
  • Row value: itemToStringValue sets what a row reads as. By default each row reads as the query.

Annotate the List render param with your item type, since like Base UI’s Autocomplete it isn’t inferred from Root’s items. Group and Item infer theirs from their props.

<Find.Root
  items={groups}
  value={query}
  onValueChange={setQuery}
  listOpen={listOpen}
  onListOpenChange={setListOpen}
  onStep={step}
  onClose={close}
>
  <Find.Bar>
    <Find.Input />
    <Find.Count current={current} total={matches.length} />
    <Find.Previous />
    <Find.Next />
    <Find.Close />
  </Find.Bar>
  <Find.List>
    {(group: FindResultGroup<Message>) => (
      <Find.Group key={group.value} group={group}>
        {(message) => (
          <Find.Item
            key={message.id}
            value={message}
            symbol={<MessageAvatar message={message} />}
            description={<Snippet message={message} query={query} />}
            meta={message.time}
            onClick={() => jumpTo(message)}
          >
            {message.author}
          </Find.Item>
        )}
      </Find.Group>
    )}
  </Find.List>
</Find.Root>

Anatomy

  • Root: the card. It takes the popup surface (bg-elevated, rounded-lg, shadow-popup) and Pipeline popups’ entry through @starting-style, since it isn’t a Base UI popup. It has no exit animation, so unmount it when it closes.
  • Bar: the 40px row the field, the count and the controls share.
  • Input: the field. Its placeholder (default “Find in page”) is also its accessible name.
  • Count: the match count. See Count.
  • Previous, Next: circular ghost IconButtons that call onStep, each with a tooltip naming its shortcut.
  • Close: the same kind of button calling onClose, with a divider before it.
  • List: the results beneath the bar, rendered while listOpen is set and items has a row (or status has something to say). Its children render each group, and its status prop holds a status row, such as a loading one, after the groups.
  • Group: a group’s label and its rows, which children renders. hideLabel keeps the label for screen readers only, for a list whose rows explain themselves.
  • Item: one result on two lines, built on InternalChoice: a leading symbol (an avatar or an icon, shown at 20px) beside the title (children) with meta such as a time at the end of its line, and a one-line description beneath. The highlighted row shows ⏎ at the end of the description’s line, under the meta, so the title line never shifts.
  • Mark: a match inside a row’s text, tinted with find-match.

Count

Count shows current/total (“2/6”) and announces “2 of 6”. Leave current unset before a match is chosen, and it shows the total alone. With total at 0 it shows empty, “No results” by default, so render it only once there’s a query. Pass children to replace the count with a status the numbers can’t say.

List

Open the list with ↓ from the field. Each row shows a snippet with the query marked, and choosing a row is yours to handle through its onClick; it fires on pointer click and on ⏎ while the row is highlighted.

1 of 4
Conversation
MCMaya Chen9:12Northwind want the pricing sheet before Thursday's call.
OOperate9:14I drafted a pricing summary from the last three calls with Northwind.
MCMaya Chen9:40Friday works. I'll send Priya the updated pricing tiers tonight.
Threads
SOSam Ortiz10:05Their procurement team asked whether pricing is per seat or per workspace.
  1. MCMaya Chen · 9:12Northwind want the pricing sheet before Thursday's call.
  2. OOperate · 9:14I drafted a pricing summary from the last three calls with Northwind.
  3. SOSam Ortiz · 9:31Looks good. Can we move the security review to Friday?
  4. MCMaya Chen · 9:40Friday works. I'll send Priya the updated pricing tiers tonight.
  5. SOSam Ortiz · 10:05 · in Northwind procurementTheir procurement team asked whether pricing is per seat or per workspace.
  6. OOperate · 10:06 · in Northwind procurementPer seat, with a workspace minimum of ten seats.
  7. MCMaya Chen · 11:20 · in Security reviewSecurity questionnaire is back, nothing blocking.

The list’s height is capped at eight two-line rows (425px), enough for every filter hint without scrolling, and it never runs past the bottom of the visual viewport, so a phone’s software keyboard doesn’t cover its last rows. To change the cap, set ---find-list-max-height through List’s className or style, which land on the list’s scroll box, where the default is declared:

<Find.List className="[---find-list-max-height:--spacing(80)]">

No results

With no matching rows, pass items={[]}: the list stays hidden even while listOpen is set, and comes back by itself when the query finds rows again. Count shows “No results”, and the stepping buttons can be disabled.

No results
  1. MCMaya Chen · 9:12Northwind want the pricing sheet before Thursday's call.
  2. OOperate · 9:14I drafted a pricing summary from the last three calls with Northwind.
  3. SOSam Ortiz · 9:31Looks good. Can we move the security review to Friday?
  4. MCMaya Chen · 9:40Friday works. I'll send Priya the updated pricing tiers tonight.
  5. SOSam Ortiz · 10:05 · in Northwind procurementTheir procurement team asked whether pricing is per seat or per workspace.
  6. OOperate · 10:06 · in Northwind procurementPer seat, with a workspace minimum of ten seats.
  7. MCMaya Chen · 11:20 · in Security reviewSecurity questionnaire is back, nothing blocking.

Keyboard

In the field:

  • ⏎ / ⇧⏎: call onStep with "next" / "previous". While the list is open with a row highlighted, ⏎ chooses that row instead.
  • ⌘G / ⇧⌘G on Apple platforms, Ctrl+G / Ctrl+⇧G elsewhere: call onStep, like the browser’s own “find again”, whether or not a row is highlighted.
  • ↓ / ↑: with the list closed and a row to show, ask to open it; with no rows they do nothing. With it open, Base UI moves the highlight through the rows.
  • Esc: with the list open, ask to close it; otherwise call onClose. The field prevents the key’s default and stops it propagating.

Accessibility

  • Count is an Autocomplete.Status, so changes to it are announced. It shows “2/6” but reads “2 of 6”, and reads “6 results” before a match is chosen.
  • With inline, Base UI sets no aria-expanded on the field, and aria-controls names the list only while it’s shown.
  • Previous, Next and Close take their accessible names from their label props, which default to “Previous match”, “Next match” and “Close”.

Theme tokens

Find.Mark tints matches in a row with find-match. Hosts that mark matches in the view being searched, such as a transcript, use the same tokens:

  • find-match: behind every match, a light green tint (16% in light, 22% in dark).
  • find-match-current and find-match-current-foreground: the match Find is on, solid and inverted so it stands out among the others (green-700 under white text in light, a light green under near-black text in dark).

With the CSS Custom Highlight API they read as --color-find-match, --color-find-match-current and --color-find-match-current-foreground:

::highlight(find-match) {
  background-color: var(--color-find-match);
}

::highlight(find-match-current) {
  background-color: var(--color-find-match-current);
  color: var(--color-find-match-current-foreground);
}

::highlight() can’t round corners, so marks in a transcript are square, while Find.Mark is rounded-xs.