Skip to content
  • Introduction
    nativecn is shadcn for Expo apps: Components you copy into your app and own, built so AI agents can build and verify whole mobile apps.
  • Installation
    Create a new Expo app with nativecn, or add nativecn to an existing one.
  • components.json
    The project file that records your Preset, Structure, aliases and agents.
  • CLI
    Every nativecn-cli command and flag: init, create, add, agents and mcp.
  • Changelog
    What changed in each nativecn release.
  • Theming
    Tokens, Colour Roles, Scale, Scheme, createStyles and fonts.
  • Presets & Styles
    Style, Base Colour, Accent Colour, Radius and fonts: chosen once, baked into your code.
  • Folder structure
    Flat and feature Structures, Destinations and the placement rule.
  • AI agents
    Rules, Skills, the nativecn MCP server, the Plugin and the Visual QA Loop.
  • Alert
    Alert, AlertTitle and AlertDescription: an inline message with Variants (default, destructive, success, warning, info), each with a default icon.
  • Avatar
    Round user image (expo-image) with fallback initials, in sizes sm, md and lg.
  • Badge
    Small status label with Variants default, secondary, outline, destructive, success and warning.
  • Button
    Button with Variants (primary, secondary, outline, ghost, destructive, link), Sizes, an icon slot, loading and status; icon-only requires aria-label.
  • Card
    Card surface with CardHeader, CardTitle, CardDescription, CardContent and CardFooter; onPress makes the whole card pressable.
  • Checkbox
    Checkbox with an optional label, controlled or uncontrolled, an indeterminate (mixed) state and a haptic tick.
  • Chip
    Chip (plain, toggle or removable, with an optional icon) and ChipGroup (single or multiple choice on the SelectionGroup Primitive), with a haptic tick.
  • Container
    Wraps a Screen: safe-area edges, scrolling, keyboard avoidance, Token padding and a max content width.
  • Drawer
    Drawer panel content for Expo Router's drawer (expo-router/drawer): DrawerContent, DrawerHeader, DrawerSection, DrawerItem (label, icon, href, badge; active from the current route) and DrawerFooter.
  • Empty State
    What a Screen or list shows when there is nothing to show yet: an icon, a title, a description and an action.
  • FormField
    Lays out a control with its Label, description and error, and links them for screen readers ("Email, text field, Enter a valid email").
  • Icon
    Every glyph: a Lucide icon at a Token size in a Colour Role, decorative unless labelled.
  • Input
    Single-line text field with Sizes, a leading icon, error/success status, a focus ring and an automatic Show/Hide password toggle.
  • InputOTP
    One-time code field: one hidden input drives N cells; numeric keyboard, SMS/Keychain autofill, paste, onComplete, error/success status and secure.
  • Label
    A field's visible label in the label type Variant, with a required marker read as "required".
  • List
    Settings-style rows: ListSection, ListSectionHeader, ListSectionFooter and ListItem (title, description, icon, trailing, chevron, destructive, onPress); FlashList-safe.
  • Progress
    A horizontal progress bar: a value 0–100 that animates, or indeterminate; role progressbar with its value.
  • RadioGroup
    RadioGroup and RadioGroupItem: a single choice from a list on the SelectionGroup Primitive, vertical or horizontal, with a haptic tick.
  • SchemeSwitcher
    Theme switcher / dark mode toggle: System, Light and Dark as SegmentedTabs, or one icon button cycling through them. Calls setScheme, which persists the choice.
  • SearchField
    Search box with a search icon, an automatic clear (×) button, a loading spinner and onSubmit from the keyboard's Search key.
  • SegmentedTabs
    In-screen tabs (not navigation): segmented or underline Variants, an animated indicator, and only the selected tab's content rendered.
  • Separator
    A hairline divider, horizontal or vertical, decorative by default.
  • Skeleton
    A placeholder shape shown while content loads; pulses unless Reduce Motion is on, hidden from screen readers.
  • Slider
    Pick a number in a range by dragging or tapping (Gesture Handler + Reanimated), snapped to steps with a haptic tick; adjustable for screen readers.
  • Spinner
    An indeterminate loading indicator in a Colour Role, announced as busy; still under Reduce Motion.
  • Switch
    A themed on/off Switch, identical on iOS and Android, with an optional label, a thumb that slides (instant under Reduce Motion) and a haptic tick.
  • TabNavigation
    The bottom tab bar: the custom tabBar of an Expo Router JS Tabs Layout, with classic and floating Variants, per-tab icons and badges from Tabs.Screen options.
  • Text
    All text: the type ramp as Variants, text Colour Roles, alignment and the font-scaling switch.
  • Textarea
    Multi-line text field that grows from minRows to maxRows, with a character counter when maxLength is set.
  • Toast
    Sonner-style Toasts: a <Toaster /> in the root Layout and toast(), toast.success/error/info/warning/dismiss. At most 3 stacked, 4s auto-dismiss, swipe away, announced, above native modals.
  • Profile header + grouped sections
    Drawer panel with a profile header (avatar, name, email, plan Badge), labelled sections (Main, Workspace, Support) and Log out pinned in the footer.
  • SaaS workspace
    Drawer panel for a SaaS workspace: logo and name with a close button, an Accent Colour onboarding item with a 5 Steps Badge, main items, a bottom group (Notifications with a count, Settings, Docs, Help) and a footer with a team switcher and the user's avatar.
  • Cover header
    Drawer panel with a dark cover (large avatar, name, View profile, a bell with a count, an overflow button and a round + action), icon items active in Accent Colour text, a divider and text-only secondary items.
  • Classic centred
    A centred sign-in Screen: logo, email and password, Forgot password, an "or" divider, then Apple and Google, and a Sign up link.
  • Social-first hero
    A social-first sign-in Screen: a dark brand hero (logo, headline, subtitle, centred) above a panel where Apple and Google lead and "Continue with email" reveals the email form.
  • Email-first, two steps
    An email-first sign-in Screen in two steps: the email, then the password, with "Email me a code instead" switching to a 6-digit code (InputOTP). Lucide chevron-left Back buttons.
  • Classic single form
    A classic sign-up Screen on one scroll: Apple and Google on top, "or", then name, email and password, a terms Checkbox with Terms and Privacy Policy links, Create account, and a Sign in link.
  • Step-by-step wizard
    A sign-up wizard, one question per step under a Progress bar: email, name, password, then a 6-digit code (InputOTP). Lucide chevron-left Back buttons; the Continue button rides above the keyboard.
  • Social-first + verify
    A brand-led, passwordless sign-up Screen: the logo and headline centred above Apple and Google; "Sign up with email" reveals the email field, and Send code moves on to a 6-digit code (InputOTP).
  • announce
    Reliable screen-reader announcements on iOS and Android (Button and Input status, Toast, error, success and warning Alerts).
  • FocusChain
    Automatic Next/Done between fields in on-screen order; Done submits the form.
  • FormField context
    Links a field to its label, description and error for screen readers; passes status, disabled and required down.
  • Keyboard
    KeyboardProvider setup, a scroll view that keeps the focused field above the keyboard, and a footer that keeps a form's main button above it.
  • Portal
    Renders content above every Screen (above native modals on iOS), layered and safe-area aware.
  • Pressable
    Pressed look from a Style Slot, tap area extended to 48, no handlers while disabled or loading, optional haptics, role and aria-* states.
  • SelectionGroup
    Single or multiple choice within a group, with radio, tab or checkbox roles and selected states.
  • useControllableState
    One implementation of controlled and uncontrolled values for form controls.
  • useMotion
    Enter/exit animations and timing/spring configs from the motion Tokens; instant when Reduce Motion is on.
  • Theme
    Tokens, Scale, Colour Roles, ThemeProvider, createStyles and the navigation theme.

SearchField

Search box with a search icon, an automatic clear (×) button, a loading spinner and onSubmit from the keyboard's Search key.

View as Markdown
Componentforms
Style

Light

Dark

Your app is ready.

Inter body text, Inter headings.

smmdlgxl
you@example.com
Continue
Default Preset a0: Vega, neutral, neutral accent, default radius, Inter.Change Preset

Installation

$npx nativecn-cli@latest add search-field

Also installs: theme, pressable, icon, spinner, use-controllable-state.

Packages: lucide-react-native@^1.51.0, react-native-svg.

Usage

SearchField demo

A controlled SearchField with loading and onSubmit.

import { useState } from "react";

import { SearchField } from "@/components/search-field";

export function SearchFieldDemo() {
  const [query, setQuery] = useState("");
  const [loading, setLoading] = useState(false);

  return (
    <SearchField
      value={query}
      onChangeText={setQuery}
      placeholder="Search recipes"
      loading={loading}
      onSubmit={() => {
        setLoading(true);
        setTimeout(() => setLoading(false), 800);
      }}
    />
  );
}

Props

PropType and description
valuestring (controlled)
defaultValuestring (uncontrolled)
onChangeText(text: string) => void
onSubmit(value: string) => void: the keyboard's Search key
placeholderstring (default "Search"); also the accessible name
loadingboolean: the Spinner replaces the search icon; the field is announced busy
disabledboolean
refRef<TextInput>
styleViewStyle, layout only, merged last

Accessibility

  • The field has role searchbox; its name is aria-label, else the placeholder ("Search" by default).
  • While loading, the field is announced busy and the Spinner stays silent.
  • The clear button is named "Clear search", with its tap area extended to 48.
  • The frame extends the tap area to 48 above and below, and a tap there focuses the field; screen readers skip the frame.
  • disabled sets aria-disabled, makes the field read-only and hides the clear button.

Notes

The clear button appears while there is text; it clears it (calling onChangeText("")) and keeps focus. TextInput props pass through.

Source

The Vega Style's code, exactly as add copies it into your app.

{components}/search-field.tsx

import { Search, X } from "lucide-react-native";
import { useCallback, useRef, useState, type Ref } from "react";
import {
  Pressable as RNPressable,
  StyleSheet,
  TextInput,
  type StyleProp,
  type TextInputProps,
  type ViewStyle,
} from "react-native";

import { Icon } from "@/components/icon";
import { Pressable, touchTargetHitSlop } from "@/components/primitives/pressable";
import { Spinner } from "@/components/spinner";
import { useControllableState } from "@/hooks/use-controllable-state";
import { createStyles, useTheme } from "@/theme";

export type SearchFieldProps = Omit<
  TextInputProps,
  "style" | "value" | "defaultValue" | "onChangeText" | "secureTextEntry" | "multiline"
> & {
  /** The query (controlled). */
  value?: string;
  /** The starting query while uncontrolled. */
  defaultValue?: string;
  onChangeText?: (text: string) => void;
  /** Called with the query when the keyboard's Search key is pressed. */
  onSubmit?: (value: string) => void;
  /** Default `"Search"`; also the accessible name when there is no `aria-label`. */
  placeholder?: string;
  /** A spinner replaces the search icon and the field is announced busy; typing still works. */
  loading?: boolean;
  /** Not editable and dimmed. */
  disabled?: boolean;
  /** Layout only, merged last onto the root. */
  style?: StyleProp<ViewStyle>;
  ref?: Ref<TextInput>;
};

const useStyles = createStyles((t) => {
  // Not destructured straight from slot(): once inlined, that literal fails excess-property checks.
  const input = {
    height: t.controlHeight.md,
    borderRadius: t.radius.md,
    paddingHorizontal: t.spacing[3],
    borderWidth: t.borderWidth.default,
    ...t.type.body,
  };
  const { fontSize, fontFamily, letterSpacing } = input;
  return {
    root: {
      ...{
    height: t.controlHeight.md,
    borderRadius: t.radius.md,
    paddingHorizontal: t.spacing[3],
  },
      flexDirection: "row",
      alignItems: "center",
      gap: t.spacing[2],
      borderCurve: "continuous",
      borderWidth: t.borderWidth.default,
      borderColor: "transparent",
      backgroundColor: t.colors.muted,
    },
    text: {
      flex: 1,
      alignSelf: "stretch",
      margin: 0,
      padding: 0,
      color: t.colors.foreground,
      fontSize,
      fontFamily,
      letterSpacing,
    },
    // The focus ring: the `ring` Colour Role, drawn outside the frame.
    focused: {
      borderColor: t.colors.ring,
      outlineColor: t.colors.ring,
      outlineStyle: "solid",
      outlineWidth: t.borderWidth.default * 2,
    },
    disabled: { opacity: t.opacity.disabled },
    pressed: { filter: [{ brightness: 0.88 }] },
  };
});

/**
 * A search box: a search icon, the query and a clear (×) button that appears while there is
 * text. The keyboard's return key reads "Search" and calls `onSubmit(value)`. Works controlled
 * (`value` + `onChangeText`) or uncontrolled. TextInput props pass through; `style` is merged
 * last onto the root.
 */
export function SearchField({
  value,
  defaultValue,
  onChangeText,
  onSubmit,
  onSubmitEditing,
  placeholder = "Search",
  placeholderTextColor,
  loading = false,
  disabled = false,
  editable,
  "aria-label": ariaLabel,
  onFocus,
  onBlur,
  style,
  ref,
  ...props
}: SearchFieldProps) {
  const { colors, iconSize, minTouchTarget } = useTheme();
  const styles = useStyles();
  const [text, setText] = useControllableState({
    value,
    defaultValue: defaultValue ?? "",
    onChange: onChangeText,
  });
  const [focused, setFocused] = useState(false);

  const inputRef = useRef<TextInput | null>(null);
  const setRef = useCallback(
    (node: TextInput | null) => {
      inputRef.current = node;
      if (typeof ref === "function") ref(node);
      else if (ref) ref.current = node;
    },
    [ref],
  );

  const clear = () => {
    setText("");
    inputRef.current?.focus();
  };

  const side = iconSize.sm;
  const isEditable = !disabled && editable !== false;
  const frameStyle: StyleProp<ViewStyle> = [
    styles.root,
    focused ? styles.focused : null,
    disabled ? styles.disabled : null,
    style,
  ];
  // TextInput has no hitSlop, so the frame extends the tap area to 48 above and below. The
  // height is known before layout (the Style Slot, or a height set through `style`).
  const { height: frameHeight } = StyleSheet.flatten(frameStyle) ?? {};
  const hitSlop = touchTargetHitSlop(
    undefined,
    typeof frameHeight === "number" ? frameHeight : undefined,
    minTouchTarget,
  );

  return (
    // A tap on the frame or within its hitSlop focuses the field. The frame is invisible to
    // screen readers, which reach the searchbox (and the clear button) directly.
    <RNPressable
      accessible={false}
      focusable={false}
      importantForAccessibility="no"
      disabled={!isEditable}
      hitSlop={hitSlop}
      onPress={isEditable ? () => inputRef.current?.focus() : undefined}
      style={frameStyle}
    >
      {loading && !disabled ? (
        // The field itself is announced busy, so the Spinner stays silent.
        <Spinner size="md" color="mutedForeground" aria-hidden />
      ) : (
        <Icon icon={Search} size="md" color="mutedForeground" />
      )}
      <TextInput
        ref={setRef}
        role="searchbox"
        aria-label={ariaLabel ?? placeholder}
        aria-disabled={disabled}
        aria-busy={loading && !disabled}
        editable={isEditable}
        value={text}
        onChangeText={setText}
        placeholder={placeholder}
        placeholderTextColor={placeholderTextColor ?? colors.mutedForeground}
        selectionColor={colors.primary}
        returnKeyType="search"
        autoCapitalize="none"
        autoCorrect={false}
        onSubmitEditing={(e) => {
          onSubmitEditing?.(e);
          onSubmit?.(text);
        }}
        onFocus={(e) => {
          setFocused(true);
          onFocus?.(e);
        }}
        onBlur={(e) => {
          setFocused(false);
          onBlur?.(e);
        }}
        style={styles.text}
        {...props}
      />
      {text.length > 0 && !disabled ? (
        <Pressable
          aria-label="Clear search"
          size={{ width: side, height: side }}
          pressedStyle={styles.pressed}
          onPress={clear}
        >
          <Icon icon={X} size="sm" color="mutedForeground" />
        </Pressable>
      ) : null}
    </RNPressable>
  );
}