Search bar

Free · MITReact Native · Expo

An iOS-style search field: focusing it shrinks the field while a Cancel button springs in beside it and scope chips cascade in below. Clear button, loading spinner and debounced queries built in.

View as Markdown
Try it on your phone
Scan to open this demo in your phone's browser and use it with your fingers.

Installation

$ npx shadcn@latest add https://goodui.dev/native/r/search-bar.json

Then run npx expo install --fix so react-native-reanimated, react-native-svg match your Expo SDK. Files land in components/goodui-native/search-bar.

Usage

import { useState } from "react";
import { SearchBar } from "@/components/goodui-native/search-bar/search-bar";

export function ContactSearch() {
  const [query, setQuery] = useState("");
  const [scope, setScope] = useState<"all" | "people" | "teams">("all");
  return (
    <SearchBar
      placeholder="Search contacts"
      debounce={250}
      onChangeText={setQuery}
      scopes={[
        { value: "all", label: "All" },
        { value: "people", label: "People" },
        { value: "teams", label: "Teams" },
      ]}
      scope={scope}
      onScopeChange={setScope}
    />
  );
}

When to use

Use it for
  • Searching a list or the whole app: contacts, messages, products, transactions.
  • Search that hits a server: set debounce and drive loading while results load.
  • Search that can be narrowed to a category: pass scopes.
Reach for something else
  • Form fields: use text-field.
  • Switching views of the same data without typing: use segmented-control.
  • A single always-visible filter row without a query: use chips or a segmented control.

Examples

Server search with a spinner

const [loading, setLoading] = useState(false);

<SearchBar
  placeholder="Search transactions"
  debounce={300}
  loading={loading}
  onChangeText={async (q) => {
    setLoading(true);
    setResults(await api.search(q));
    setLoading(false);
  }}
/>

Navigation bar size without Cancel

<SearchBar size="sm" showCancel={false} placeholder="Filter" onChangeText={setFilter} />

API reference

PropTypeDefault
value
Query text (controlled). With `debounce`, the field keeps typing smoothly and syncs when this changes from outside.
string—
defaultValue
Initial query when uncontrolled.
string""
onChangeText
Called with the query. Debounced when `debounce` is set; clear, cancel and submit always call it right away.
(text: string) => void—
debounce
Wait this many ms after the last keystroke before calling `onChangeText`. Default 0 (every keystroke).
number0
onSubmit
Called when the search key is pressed, with the current query.
(text: string) => void—
onCancel
Called after Cancel is pressed (the field has already blurred and, by default, cleared).
() => void—
onActiveChange
Called when the field becomes active (focused or holding a query) or inactive.
(active: boolean) => void—
placeholder
Placeholder. Same as `labels.placeholder`.
string—
cancelLabel
Label of the cancel button. Same as `labels.cancel`.
string—
showCancel
Reveal a Cancel button while the field is active. Default true.
booleantrue
clearOnCancel
Cancel also clears the query. Default true.
booleantrue
scopes
Scope chips under the field, for example All, People, Messages.
SearchScope<T>[]—
scope
Selected scope value.
T—
onScopeChange
Called with the tapped scope value.
(value: T) => void—
scopesVisible
`active` reveals the chips only while searching (like iOS); `always` keeps them visible.
"active" | "always""active"
loading
Shows a spinner in place of the magnifier, for example while results load.
booleanfalse
variant
`filled` is a muted surface; `outline` adds a hairline border for plain backgrounds.
"filled" | "outline""filled"
size
Heights 36 (`sm`, navigation bars) and 44 pt (`md`).
"sm" | "md""md"
autoFocus
Focus the field on mount.
booleanfalse
haptic
`false` silences this bar's haptics (cancel, clear, scope change).
booleantrue
inputRef
Ref to the underlying TextInput.
Ref<ComponentRef<typeof TextInput>>—
accessibilityLabel
Screen reader label. Defaults to the placeholder.
string—
labels
Overrides for the built-in strings; a GooduiLocaleProvider pack sets them app-wide.
Partial<SearchBarLabels>—
style
Style for the outer container.
StyleProp<ViewStyle>—
testID
Identifier for end-to-end tests.
string—

Motion and haptics

  • Focus: the field gives up exactly the room Cancel needs while Cancel slides in from the edge, both on the morph spring.
  • The focus ring fades to the accent color.
  • Scope chips: the row's height springs open and the chips cascade in 35 ms apart on the snappy spring; they fade out together when the bar deactivates.
  • Clear scales in when there is text; chips scale down on press.
  • Reduced motion: Cancel and the chip row appear with a short fade-speed timing instead of travelling, chips do not cascade or scale.
  • Haptic: tap on Cancel and on clear
  • Haptic: select when a different scope is chosen
  • Layout changes (field width, chip row height) animate on the UI thread.
  • With debounce, typing never re-renders the parent per keystroke; onChangeText fires after the pause.

Accessibility

  • The TextInput has accessibilityRole search and is labelled by accessibilityLabel or the placeholder.
  • Clear is a button labelled 'Clear search'; Cancel is a button with its visible label. Both are hidden from screen readers while they are not shown.
  • Scope chips are tabs in a tablist with their selected state. The chip row is hidden from screen readers while collapsed.
  • The search key submits (returnKeyType and enterKeyHint are search).

Notes for AI

  • Set debounce (ms) instead of writing your own debounce around onChangeText. Clear, Cancel and submit always fire immediately.
  • It works uncontrolled; pass value only to set the query from outside (a tapped recent search). With debounce, the field ignores its own echoed value.
  • Cancel blurs and, by default, clears. Use clearOnCancel={false} to keep the query.
  • scopesVisible='active' shows chips only while searching like iOS; use 'always' for a persistent filter.
  • Inside a ScrollView set keyboardShouldPersistTaps='handled' so clear and chips work while the keyboard is up.