v0.1 public beta early access on GitHub
Home / Screens / core-search
Screen template

Search Screen

Pinned search field, recent-search rows with per-item remove, trending chips, suggested rows, and a results state with empty state

pro Expo Go ✓ iosandroid gestures: tap · scroll haptics: light · selection
$ npx lumioui add core-search
import { SearchScreen } from "@/screens/core/search";
Native packages installed6
@lumioui/ui@latest@lumioui/core@latestreact-native-safe-area-context@*react-native-reanimated@*react-native-svg@*nativewind@*
core/search.tsxsource · yours to edit
import * as React from "react";
import { useEffect, useState } from "react";
import { View, FlatList, ScrollView, Pressable } from "react-native";
import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context";
import { Button, Chip, EmptyState, Icon, Image, SearchInput, Text, type IconName } from "@lumioui/ui";
import { AnimatedPressable, cn, useHaptic, usePressAnimation, type ColorTokens } from "@lumioui/core";

import { Mount, Press, ROW_STAGGER, ROW_STAGGER_CAP, StoryScrim } from "../shared";
export interface SearchResult {
  id: string;
  title: string;
  subtitle?: string;
  imageUrl?: string;
  /** Leading icon when there's no thumbnail (e.g. "grid", "user"). */
  icon?: IconName;
}

export interface SearchScreenProps {
  /** Results for the current query. When omitted, a small built-in dataset is
   *  filtered by the query so the template feels alive without a backend. */
  results?: SearchResult[];
  recentSearches?: string[];
  trendingSearches?: string[];
  onSearch?: (query: string) => void;
  onResultPress?: (result: SearchResult) => void;
  onRecentPress?: (query: string) => void;
  onTrendingPress?: (query: string) => void;
  onRemoveRecent?: (query: string) => void;
  onClearRecent?: () => void;
}

const img = (seed: string) => `https://picsum.photos/seed/${seed}/200/200`;
const face = (n: number) => `https://i.pravatar.cc/150?img=${n}`;

const DEFAULT_RECENT = ["design tokens", "animated charts", "haptic feedback", "onboarding flow"];

const DEFAULT_TRENDING = ["Reanimated 4", "NativeWind", "Liquid glass", "Expo Router", "FlashList"];

/** Per-chip tint rotation for trending — each topic reads as its own
 *  "channel" instead of five identical pills. Semantic tokens only. */
const TRENDING_TONES: { bg: string; text: string; icon: keyof ColorTokens }[] = [
  { bg: "bg-primary/10", text: "text-primary", icon: "primary" },
  { bg: "bg-chart-2/15", text: "text-chart-2", icon: "chart2" },
  { bg: "bg-chart-3/15", text: "text-chart-3", icon: "chart3" },
  { bg: "bg-chart-5/15", text: "text-chart-5", icon: "chart5" },
  { bg: "bg-warning/15", text: "text-warning", icon: "warning" },
];

/** Idle-state suggestions — rendered as the same result rows a query
 *  produces, the App Store "Suggested" idiom. */
const DEFAULT_SUGGESTED: SearchResult[] = [
  {
    id: "s1",
    title: "Bottom sheet",
    subtitle: "Pro component · Overlays",
    imageUrl: img("lumioui-sug-1"),
  },
  {
    id: "s2",
    title: "Coach marks",
    subtitle: "Pro component · Onboarding",
    imageUrl: img("lumioui-sug-2"),
  },
  {
    id: "s3",
    title: "Paywall sheet",
    subtitle: "Pro component · Monetization",
    imageUrl: img("lumioui-sug-3"),
  },
];

const DEFAULT_RESULTS: SearchResult[] = [
  {
    id: "r1",
    title: "Animated charts",
    subtitle: "Pro component · Charts",
    imageUrl: img("lumioui-search-1"),
  },
  {
    id: "r2",
    title: "Haptic feedback",
    subtitle: "Guide · Best practices",
    imageUrl: img("lumioui-search-2"),
  },
  { id: "r3", title: "Design tokens", subtitle: "Guide · Theming", icon: "book-open" },
  {
    id: "r4",
    title: "Onboarding flow",
    subtitle: "Screen template · Auth",
    imageUrl: img("lumioui-search-4"),
  },
  { id: "r5", title: "Maya Chen", subtitle: "Product designer · 128 posts", imageUrl: face(47) },
];

interface SearchResultRowProps {
  item: SearchResult;
  onPress?: (item: SearchResult) => void;
}

function SearchResultRow({ item, onPress }: SearchResultRowProps) {
  const haptics = useHaptic();
  const press = usePressAnimation({ fast: true });
  return (
    <AnimatedPressable
      onPress={() => {
        haptics.light();
        onPress?.(item);
      }}
      onPressIn={press.onPressIn}
      onPressOut={press.onPressOut}
      style={press.animatedStyle}
      accessibilityRole="button"
      accessibilityLabel={item.subtitle ? `${item.title}, ${item.subtitle}` : item.title}
      className="min-h-11 flex-row items-center gap-3 py-2"
    >
      {item.imageUrl ? (
        <Image
          source={{ uri: item.imageUrl }}
          rounded="xl"
          className="h-11 w-11"
          accessibilityLabel={item.title}
        />
      ) : (
        <View className="h-11 w-11 items-center justify-center rounded-xl bg-[#F4F5F9] dark:bg-white/10">
          <Icon name={item.icon ?? "search"} size="sm" color="mutedForeground" />
        </View>
      )}
      <View className="flex-1 gap-0.5">
        <Text variant="small" className="font-semibold" numberOfLines={1}>
          {item.title}
        </Text>
        {item.subtitle ? (
          <Text variant="muted" numberOfLines={1}>
            {item.subtitle}
          </Text>
        ) : null}
      </View>
      <Icon name="chevron-right" size="sm" color="mutedForeground" />
    </AnimatedPressable>
  );
}
SearchResultRow.displayName = "SearchResultRow";

/** Section label over the idle screen — muted uppercase microcopy built
 *  from explicit tokens on top of the muted variant. */
function SectionLabel({ children }: { children: React.ReactNode }) {
  return (
    <Text
      variant="muted"
      accessibilityRole="header"
      className={cn("text-xs font-semibold uppercase tracking-wider")}
    >
      {children}
    </Text>
  );
}
SectionLabel.displayName = "SectionLabel";

interface RecentRowProps {
  term: string;
  onPress: (term: string) => void;
  onRemove: (term: string) => void;
}

/** Recent-search row — clock glyph, term, per-item remove. */
function RecentRow({ term, onPress, onRemove }: RecentRowProps) {
  const haptics = useHaptic();
  const press = usePressAnimation({ fast: true });
  return (
    <AnimatedPressable
      onPress={() => {
        haptics.light();
        onPress(term);
      }}
      onPressIn={press.onPressIn}
      onPressOut={press.onPressOut}
      style={press.animatedStyle}
      accessibilityRole="button"
      accessibilityLabel={`Search for ${term}`}
      className="min-h-11 flex-row items-center gap-3 py-1.5"
    >
      <Icon name="clock" size="sm" color="mutedForeground" />
      <Text variant="small" className="flex-1" numberOfLines={1}>
        {term}
      </Text>
      <Pressable
        onPress={() => {
          haptics.light();
          onRemove(term);
        }}
        accessibilityRole="button"
        accessibilityLabel={`Remove ${term} from recent searches`}
        hitSlop={10}
        className="h-8 w-8 items-center justify-center"
      >
        <Icon name="x" size="sm" color="mutedForeground" />
      </Pressable>
    </AnimatedPressable>
  );
}
RecentRow.displayName = "RecentRow";

/** Search — pinned autofocus field, recent rows, trending chips, results. */
export function SearchScreen({
  results,
  recentSearches = DEFAULT_RECENT,
  trendingSearches = DEFAULT_TRENDING,
  onSearch,
  onResultPress,
  onRecentPress,
  onTrendingPress,
  onRemoveRecent,
  onClearRecent,
}: SearchScreenProps) {
  const haptics = useHaptic();
  const insets = useSafeAreaInsets();
  const [query, setQuery] = useState("");
  // Local mirror of recents so remove/clear feel alive when the consumer
  // hasn't wired state yet; prop changes re-sync.
  const [recents, setRecents] = useState(recentSearches);
  useEffect(() => setRecents(recentSearches), [recentSearches]);

  const updateQuery = (text: string) => {
    setQuery(text);
    onSearch?.(text);
  };

  const runSearch = (term: string) => {
    updateQuery(term);
  };

  const removeRecent = (term: string) => {
    setRecents((prev) => prev.filter((t) => t !== term));
    onRemoveRecent?.(term);
  };

  const clearRecents = () => {
    setRecents([]);
    onClearRecent?.();
  };

  const trimmed = query.trim();
  const searching = trimmed.length > 0;
  const lower = trimmed.toLowerCase();
  const activeResults =
    results ??
    DEFAULT_RESULTS.filter((r) =>
      `${r.title} ${r.subtitle ?? ""}`.toLowerCase().includes(lower)
    );

  return (
    <SafeAreaView className="flex-1 bg-[#F4F5F9] dark:bg-[#111114]" edges={["top"]}>
      {/* Pinned header — the field never scrolls away. */}
      <Mount>
        <View className="gap-3 px-5 pb-3 pt-4">
          <Text variant="h2" accessibilityRole="header">
            Search
          </Text>
          <SearchInput
            glass
            placeholder="Components, guides, people…"
            value={query}
            onChangeText={updateQuery}
            onClear={() => updateQuery("")}
            // autoFocus removed to prevent auto-scrolling embedded previews
            returnKeyType="search"
            accessibilityLabel="Search"
          />
          {/* Query hint — real searches teach the grammar once. */}
          <Text variant="muted" className="pt-2 text-xs">
            Try "glass" — or filter with tag:motion
          </Text>
        </View>
      </Mount>

      {!searching ? (
        <ScrollView
          showsVerticalScrollIndicator={false}
          keyboardShouldPersistTaps="handled"
          contentContainerStyle={{ paddingBottom: insets.bottom + 32 }}
        >
          {/* Featured — a photo-led discovery card so the idle state opens
              with something worth tapping, not a list of chores. */}
          <Mount>
            <Press
              haptic="light"
              onPress={() => runSearch("bottom sheet")}
              accessibilityRole="button"
              accessibilityLabel="Featured: Bottom sheet, new this week"
              className="mx-6 mt-1 h-36 overflow-hidden rounded-3xl p-0"
            >
              <Image
                source={{ uri: "https://picsum.photos/seed/lumioui-featured/560/288" }}
                className="absolute inset-0 h-full w-full"
                accessibilityElementsHidden
              />
              <View className="absolute inset-x-0 bottom-0 h-20">
                <StoryScrim edge="bottom" />
              </View>
              <View className="absolute left-3 top-3 rounded-full bg-[#BEF04C] px-2.5 py-1">
                <Text className="text-[11px] font-semibold text-[#1B1B26]">
                  Featured this week
                </Text>
              </View>
              <View className="absolute inset-x-0 bottom-0 p-4">
                <Text className="text-base font-semibold text-white">Bottom sheet</Text>
                <Text className="text-xs text-white/75">
                  Pro component · inertia dismissal, keyboard-aware
                </Text>
              </View>
            </Press>
          </Mount>

          {/* Recent searches */}
          <Mount delay={50}>
            <View className="flex-row items-center justify-between px-6 pt-3">
              <SectionLabel>Recent</SectionLabel>
              {recents.length > 0 ? (
                <Pressable
                  onPress={() => {
                    haptics.light();
                    clearRecents();
                  }}
                  accessibilityRole="button"
                  accessibilityLabel="Clear all recent searches"
                  hitSlop={8}
                  className="min-h-11 justify-center"
                >
                  <Text variant="small" className="text-primary">
                    Clear all
                  </Text>
                </Pressable>
              ) : null}
            </View>
            <View className="px-6 pt-1">
              {recents.map((term) => (
                <RecentRow
                  key={term}
                  term={term}
                  onPress={(t) => {
                    runSearch(t);
                    onRecentPress?.(t);
                  }}
                  onRemove={removeRecent}
                />
              ))}
              {recents.length === 0 ? (
                <Text variant="muted" className="py-2">
                  No recent searches.
                </Text>
              ) : null}
            </View>
          </Mount>

          {/* Trending */}
          {trendingSearches.length > 0 ? (
            <Mount delay={110}>
              <View className="px-6 pt-6">
                <SectionLabel>Trending</SectionLabel>
                <View className="flex-row flex-wrap gap-2 pt-3">
                  {trendingSearches.map((term, i) => {
                    const tone = TRENDING_TONES[i % TRENDING_TONES.length] ?? TRENDING_TONES[0]!;
                    return (
                      <Chip
                        key={term}
                        variant="suggestion"
                        icon={<Icon name="flame" size="sm" color={tone.icon} />}
                        onPress={() => {
                          runSearch(term);
                          onTrendingPress?.(term);
                        }}
                        accessibilityLabel={`Search trending topic ${term}`}
                        className={cn("min-h-11 px-4", tone.bg)}
                        textClassName={tone.text}
                      >
                        {term}
                      </Chip>
                    );
                  })}
                </View>
              </View>
            </Mount>
          ) : null}

          {/* Suggested — result-style rows so the idle screen still ends
              with content, the way App Store search does. */}
          <Mount delay={170}>
            <View className="px-6 pt-6">
              <SectionLabel>Suggested</SectionLabel>
              <View className="pt-2">
                {DEFAULT_SUGGESTED.map((item, i) => (
                  <View key={item.id}>
                    <SearchResultRow item={item} onPress={onResultPress} />
                    {i < DEFAULT_SUGGESTED.length - 1 ? (
                      <View className="ml-[56px] h-px bg-border/60" />
                    ) : null}
                  </View>
                ))}
              </View>
            </View>
          </Mount>
        </ScrollView>
      ) : (
        <FlatList
          data={activeResults}
          keyExtractor={(item) => item.id}
          keyboardShouldPersistTaps="handled"
          contentContainerClassName="gap-1 px-6 pt-2"
          contentContainerStyle={{ paddingBottom: insets.bottom + 32 }}
          ListEmptyComponent={
            <EmptyState
              variant="search"
              title="No results"
              description={`Nothing matched “${trimmed}”. Try a different term.`}
              action={
                <Button size="sm" variant="secondary" onPress={() => updateQuery("")}>
                  Clear search
                </Button>
              }
              className="py-20"
            />
          }
          renderItem={({ item, index }) => (
            <Mount delay={Math.min(index * ROW_STAGGER, ROW_STAGGER_CAP)}>
              <SearchResultRow item={item} onPress={onResultPress} />
            </Mount>
          )}
        />
      )}
    </SafeAreaView>
  );
}
agent prompt — paste into Claude Code, Cursor, Devin…
Recreate the "Search Screen" screen in React Native using LumioUI.

Goal: Pinned search field, recent-search rows with per-item remove, trending chips, suggested rows, and a results state with empty state Match the reference implementation's layout, behavior, and motion exactly — this is a production screen, not a mockup.

## Setup

Fastest path — install the screen plus its component deps:

```bash
npx lumioui add core-search
```

Or pull just the components: `npx lumioui add button chip empty-state glass icon image search-input text`

Native packages:
- @lumioui/ui@latest
- @lumioui/core@latest
- react-native-safe-area-context@*
- react-native-reanimated@*
- react-native-svg@*
- nativewind@*

Stack assumptions: Expo SDK 54 / RN 0.81, Reanimated 4, NativeWind v4, TypeScript strict, Expo Go compatible.

## Components — how, where, when

### `button` — Tactile pill button with press spring, ink wash and async loading.
- `<Button>` ×1

### `chip` — Compact selectable pill with press scale and ink overlay.
- `<Chip>` ×1

### `empty-state` — Illustrated placeholder with title, copy and action slot.
- `<EmptyState>` ×1

### `glass` — iOS 26 liquid-glass surface — real glass via expo-glass-effect (Expo Go) or @callstack/liquid-glass (dev builds), SVG refraction + specular rim on web, frosted fallback elsewhere; GlassContainer melts adjacent panes into one blob
- imported but not rendered directly — likely used through a sibling or as a utility

### `icon` — Lucide icon set mapped to theme colors and sizes.
- `<Icon>` ×5

### `image` — Cached image with placeholder, fallback and aspect handling.
- `<Image>` ×2

### `search-input` — Search field with clear button, debounce, focus halo — plus a glass capsule mode that floats over content like iOS 26
- `<SearchInput>` ×1

### `text` — Themed type scale from caption to display.
- `<Text>` ×11

## Conventions (non-negotiable)

- Styling via NativeWind `className` with semantic tokens only: `bg-background`, `text-foreground`, `bg-card`, `text-card-foreground`, `bg-secondary`, `bg-muted`, `text-muted-foreground`, `bg-accent`, `bg-destructive`, `border-border`. Hard-coded colors only inside media overlays (`text-white`, `bg-black/N`).
- Motion via the token presets from `@lumioui/core` (`spring.press`, `spring.surface`, `spring.glide`, `timing.fast`, `timing.slow`, …) — no bespoke spring configs or durations.
- Gate transform motion behind `useReducedMotion()`; keep opacity/color feedback, drop travel and overshoot.
- Haptics via `useHaptic()`: `light` on press, `selection` on toggle/select, `warning` on invalid input. Never import expo-haptics directly.
- Press feedback via `usePressAnimation()` (~0.97 scale), not `activeOpacity` tricks.
- a11y: `accessibilityRole` + `accessibilityLabel` on every control, touch targets ≥44pt.
- Entrance choreography: staggered `Mount` wrappers (delay 0/60/120…) so regions land in sequence instead of popping in at once.
- Wrap the screen in `SafeAreaView`; keep dark mode working via the semantic tokens.

## Deliverable

One file: `screens/core/search.tsx` exporting SearchScreen. Preserve the props interface — the host app wires the callbacks (navigation, submit handlers) through them.

## Reference implementation

The LumioUI source for this screen — reproduce it faithfully:

```tsx
import * as React from "react";
import { useEffect, useState } from "react";
import { View, FlatList, ScrollView, Pressable } from "react-native";
import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context";
import { Button, Chip, EmptyState, Icon, Image, SearchInput, Text, type IconName } from "@lumioui/ui";
import { AnimatedPressable, cn, useHaptic, usePressAnimation, type ColorTokens } from "@lumioui/core";

import { Mount, Press, ROW_STAGGER, ROW_STAGGER_CAP, StoryScrim } from "../shared";
export interface SearchResult {
  id: string;
  title: string;
  subtitle?: string;
  imageUrl?: string;
  /** Leading icon when there's no thumbnail (e.g. "grid", "user"). */
  icon?: IconName;
}

export interface SearchScreenProps {
  /** Results for the current query. When omitted, a small built-in dataset is
   *  filtered by the query so the template feels alive without a backend. */
  results?: SearchResult[];
  recentSearches?: string[];
  trendingSearches?: string[];
  onSearch?: (query: string) => void;
  onResultPress?: (result: SearchResult) => void;
  onRecentPress?: (query: string) => void;
  onTrendingPress?: (query: string) => void;
  onRemoveRecent?: (query: string) => void;
  onClearRecent?: () => void;
}

const img = (seed: string) => `https://picsum.photos/seed/${seed}/200/200`;
const face = (n: number) => `https://i.pravatar.cc/150?img=${n}`;

const DEFAULT_RECENT = ["design tokens", "animated charts", "haptic feedback", "onboarding flow"];

const DEFAULT_TRENDING = ["Reanimated 4", "NativeWind", "Liquid glass", "Expo Router", "FlashList"];

/** Per-chip tint rotation for trending — each topic reads as its own
 *  "channel" instead of five identical pills. Semantic tokens only. */
const TRENDING_TONES: { bg: string; text: string; icon: keyof ColorTokens }[] = [
  { bg: "bg-primary/10", text: "text-primary", icon: "primary" },
  { bg: "bg-chart-2/15", text: "text-chart-2", icon: "chart2" },
  { bg: "bg-chart-3/15", text: "text-chart-3", icon: "chart3" },
  { bg: "bg-chart-5/15", text: "text-chart-5", icon: "chart5" },
  { bg: "bg-warning/15", text: "text-warning", icon: "warning" },
];

/** Idle-state suggestions — rendered as the same result rows a query
 *  produces, the App Store "Suggested" idiom. */
const DEFAULT_SUGGESTED: SearchResult[] = [
  {
    id: "s1",
    title: "Bottom sheet",
    subtitle: "Pro component · Overlays",
    imageUrl: img("lumioui-sug-1"),
  },
  {
    id: "s2",
    title: "Coach marks",
    subtitle: "Pro component · Onboarding",
    imageUrl: img("lumioui-sug-2"),
  },
  {
    id: "s3",
    title: "Paywall sheet",
    subtitle: "Pro component · Monetization",
    imageUrl: img("lumioui-sug-3"),
  },
];

const DEFAULT_RESULTS: SearchResult[] = [
  {
    id: "r1",
    title: "Animated charts",
    subtitle: "Pro component · Charts",
    imageUrl: img("lumioui-search-1"),
  },
  {
    id: "r2",
    title: "Haptic feedback",
    subtitle: "Guide · Best practices",
    imageUrl: img("lumioui-search-2"),
  },
  { id: "r3", title: "Design tokens", subtitle: "Guide · Theming", icon: "book-open" },
  {
    id: "r4",
    title: "Onboarding flow",
    subtitle: "Screen template · Auth",
    imageUrl: img("lumioui-search-4"),
  },
  { id: "r5", title: "Maya Chen", subtitle: "Product designer · 128 posts", imageUrl: face(47) },
];

interface SearchResultRowProps {
  item: SearchResult;
  onPress?: (item: SearchResult) => void;
}

function SearchResultRow({ item, onPress }: SearchResultRowProps) {
  const haptics = useHaptic();
  const press = usePressAnimation({ fast: true });
  return (
    <AnimatedPressable
      onPress={() => {
        haptics.light();
        onPress?.(item);
      }}
      onPressIn={press.onPressIn}
      onPressOut={press.onPressOut}
      style={press.animatedStyle}
      accessibilityRole="button"
      accessibilityLabel={item.subtitle ? `${item.title}, ${item.subtitle}` : item.title}
      className="min-h-11 flex-row items-center gap-3 py-2"
    >
      {item.imageUrl ? (
        <Image
          source={{ uri: item.imageUrl }}
          rounded="xl"
          className="h-11 w-11"
          accessibilityLabel={item.title}
        />
      ) : (
        <View className="h-11 w-11 items-center justify-center rounded-xl bg-[#F4F5F9] dark:bg-white/10">
          <Icon name={item.icon ?? "search"} size="sm" color="mutedForeground" />
        </View>
      )}
      <View className="flex-1 gap-0.5">
        <Text variant="small" className="font-semibold" numberOfLines={1}>
          {item.title}
        </Text>
        {item.subtitle ? (
          <Text variant="muted" numberOfLines={1}>
            {item.subtitle}
          </Text>
        ) : null}
      </View>
      <Icon name="chevron-right" size="sm" color="mutedForeground" />
    </AnimatedPressable>
  );
}
SearchResultRow.displayName = "SearchResultRow";

/** Section label over the idle screen — muted uppercase microcopy built
 *  from explicit tokens on top of the muted variant. */
function SectionLabel({ children }: { children: React.ReactNode }) {
  return (
    <Text
      variant="muted"
      accessibilityRole="header"
      className={cn("text-xs font-semibold uppercase tracking-wider")}
    >
      {children}
    </Text>
  );
}
SectionLabel.displayName = "SectionLabel";

interface RecentRowProps {
  term: string;
  onPress: (term: string) => void;
  onRemove: (term: string) => void;
}

/** Recent-search row — clock glyph, term, per-item remove. */
function RecentRow({ term, onPress, onRemove }: RecentRowProps) {
  const haptics = useHaptic();
  const press = usePressAnimation({ fast: true });
  return (
    <AnimatedPressable
      onPress={() => {
        haptics.light();
        onPress(term);
      }}
      onPressIn={press.onPressIn}
      onPressOut={press.onPressOut}
      style={press.animatedStyle}
      accessibilityRole="button"
      accessibilityLabel={`Search for ${term}`}
      className="min-h-11 flex-row items-center gap-3 py-1.5"
    >
      <Icon name="clock" size="sm" color="mutedForeground" />
      <Text variant="small" className="flex-1" numberOfLines={1}>
        {term}
      </Text>
      <Pressable
        onPress={() => {
          haptics.light();
          onRemove(term);
        }}
        accessibilityRole="button"
        accessibilityLabel={`Remove ${term} from recent searches`}
        hitSlop={10}
        className="h-8 w-8 items-center justify-center"
      >
        <Icon name="x" size="sm" color="mutedForeground" />
      </Pressable>
    </AnimatedPressable>
  );
}
RecentRow.displayName = "RecentRow";

/** Search — pinned autofocus field, recent rows, trending chips, results. */
export function SearchScreen({
  results,
  recentSearches = DEFAULT_RECENT,
  trendingSearches = DEFAULT_TRENDING,
  onSearch,
  onResultPress,
  onRecentPress,
  onTrendingPress,
  onRemoveRecent,
  onClearRecent,
}: SearchScreenProps) {
  const haptics = useHaptic();
  const insets = useSafeAreaInsets();
  const [query, setQuery] = useState("");
  // Local mirror of recents so remove/clear feel alive when the consumer
  // hasn't wired state yet; prop changes re-sync.
  const [recents, setRecents] = useState(recentSearches);
  useEffect(() => setRecents(recentSearches), [recentSearches]);

  const updateQuery = (text: string) => {
    setQuery(text);
    onSearch?.(text);
  };

  const runSearch = (term: string) => {
    updateQuery(term);
  };

  const removeRecent = (term: string) => {
    setRecents((prev) => prev.filter((t) => t !== term));
    onRemoveRecent?.(term);
  };

  const clearRecents = () => {
    setRecents([]);
    onClearRecent?.();
  };

  const trimmed = query.trim();
  const searching = trimmed.length > 0;
  const lower = trimmed.toLowerCase();
  const activeResults =
    results ??
    DEFAULT_RESULTS.filter((r) =>
      `${r.title} ${r.subtitle ?? ""}`.toLowerCase().includes(lower)
    );

  return (
    <SafeAreaView className="flex-1 bg-[#F4F5F9] dark:bg-[#111114]" edges={["top"]}>
      {/* Pinned header — the field never scrolls away. */}
      <Mount>
        <View className="gap-3 px-5 pb-3 pt-4">
          <Text variant="h2" accessibilityRole="header">
            Search
          </Text>
          <SearchInput
            glass
            placeholder="Components, guides, people…"
            value={query}
            onChangeText={updateQuery}
            onClear={() => updateQuery("")}
            // autoFocus removed to prevent auto-scrolling embedded previews
            returnKeyType="search"
            accessibilityLabel="Search"
          />
          {/* Query hint — real searches teach the grammar once. */}
          <Text variant="muted" className="pt-2 text-xs">
            Try "glass" — or filter with tag:motion
          </Text>
        </View>
      </Mount>

      {!searching ? (
        <ScrollView
          showsVerticalScrollIndicator={false}
          keyboardShouldPersistTaps="handled"
          contentContainerStyle={{ paddingBottom: insets.bottom + 32 }}
        >
          {/* Featured — a photo-led discovery card so the idle state opens
              with something worth tapping, not a list of chores. */}
          <Mount>
            <Press
              haptic="light"
              onPress={() => runSearch("bottom sheet")}
              accessibilityRole="button"
              accessibilityLabel="Featured: Bottom sheet, new this week"
              className="mx-6 mt-1 h-36 overflow-hidden rounded-3xl p-0"
            >
              <Image
                source={{ uri: "https://picsum.photos/seed/lumioui-featured/560/288" }}
                className="absolute inset-0 h-full w-full"
                accessibilityElementsHidden
              />
              <View className="absolute inset-x-0 bottom-0 h-20">
                <StoryScrim edge="bottom" />
              </View>
              <View className="absolute left-3 top-3 rounded-full bg-[#BEF04C] px-2.5 py-1">
                <Text className="text-[11px] font-semibold text-[#1B1B26]">
                  Featured this week
                </Text>
              </View>
              <View className="absolute inset-x-0 bottom-0 p-4">
                <Text className="text-base font-semibold text-white">Bottom sheet</Text>
                <Text className="text-xs text-white/75">
                  Pro component · inertia dismissal, keyboard-aware
                </Text>
              </View>
            </Press>
          </Mount>

          {/* Recent searches */}
          <Mount delay={50}>
            <View className="flex-row items-center justify-between px-6 pt-3">
              <SectionLabel>Recent</SectionLabel>
              {recents.length > 0 ? (
                <Pressable
                  onPress={() => {
                    haptics.light();
                    clearRecents();
                  }}
                  accessibilityRole="button"
                  accessibilityLabel="Clear all recent searches"
                  hitSlop={8}
                  className="min-h-11 justify-center"
                >
                  <Text variant="small" className="text-primary">
                    Clear all
                  </Text>
                </Pressable>
              ) : null}
            </View>
            <View className="px-6 pt-1">
              {recents.map((term) => (
                <RecentRow
                  key={term}
                  term={term}
                  onPress={(t) => {
                    runSearch(t);
                    onRecentPress?.(t);
                  }}
                  onRemove={removeRecent}
                />
              ))}
              {recents.length === 0 ? (
                <Text variant="muted" className="py-2">
                  No recent searches.
                </Text>
              ) : null}
            </View>
          </Mount>

          {/* Trending */}
          {trendingSearches.length > 0 ? (
            <Mount delay={110}>
              <View className="px-6 pt-6">
                <SectionLabel>Trending</SectionLabel>
                <View className="flex-row flex-wrap gap-2 pt-3">
                  {trendingSearches.map((term, i) => {
                    const tone = TRENDING_TONES[i % TRENDING_TONES.length] ?? TRENDING_TONES[0]!;
                    return (
                      <Chip
                        key={term}
                        variant="suggestion"
                        icon={<Icon name="flame" size="sm" color={tone.icon} />}
                        onPress={() => {
                          runSearch(term);
                          onTrendingPress?.(term);
                        }}
                        accessibilityLabel={`Search trending topic ${term}`}
                        className={cn("min-h-11 px-4", tone.bg)}
                        textClassName={tone.text}
                      >
                        {term}
                      </Chip>
                    );
                  })}
                </View>
              </View>
            </Mount>
          ) : null}

          {/* Suggested — result-style rows so the idle screen still ends
              with content, the way App Store search does. */}
          <Mount delay={170}>
            <View className="px-6 pt-6">
              <SectionLabel>Suggested</SectionLabel>
              <View className="pt-2">
                {DEFAULT_SUGGESTED.map((item, i) => (
                  <View key={item.id}>
                    <SearchResultRow item={item} onPress={onResultPress} />
                    {i < DEFAULT_SUGGESTED.length - 1 ? (
                      <View className="ml-[56px] h-px bg-border/60" />
                    ) : null}
                  </View>
                ))}
              </View>
            </View>
          </Mount>
        </ScrollView>
      ) : (
        <FlatList
          data={activeResults}
          keyExtractor={(item) => item.id}
          keyboardShouldPersistTaps="handled"
          contentContainerClassName="gap-1 px-6 pt-2"
          contentContainerStyle={{ paddingBottom: insets.bottom + 32 }}
          ListEmptyComponent={
            <EmptyState
              variant="search"
              title="No results"
              description={`Nothing matched “${trimmed}”. Try a different term.`}
              action={
                <Button size="sm" variant="secondary" onPress={() => updateQuery("")}>
                  Clear search
                </Button>
              }
              className="py-20"
            />
          }
          renderItem={({ item, index }) => (
            <Mount delay={Math.min(index * ROW_STAGGER, ROW_STAGGER_CAP)}>
              <SearchResultRow item={item} onPress={onResultPress} />
            </Mount>
          )}
        />
      )}
    </SafeAreaView>
  );
}
```

Screen: https://lumioui.dev/screens/core-search
iPhone 17 · iOS
9:41
Live preview — the real screen via Expo Web · open full ↗