Travel discovery: search, filter chips, recent searches, photo-card destinations
import * as React from "react";
import { useState } from "react";
import { Image, ScrollView, View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { Chip, Icon, SearchInput, Text } from "@lumioui/ui";
import { cn, useHaptic } from "@lumioui/core";
import { Mount, Press, SERIF, StoryScrim, World } from "../shared";
const destImg = (id: string) => `https://images.unsplash.com/${id}?w=600&h=750&fit=crop&q=70&auto=format`;
interface RecentSearch {
id: string;
place: string;
detail: string;
}
interface Destination {
id: string;
name: string;
country: string;
price: number;
tag: "Nearby" | "Explore" | "Road Trip";
imageUrl: string;
}
export interface DiscoverScreenProps {
recent?: RecentSearch[];
destinations?: Destination[];
onSearch?: (q: string) => void;
onDestinationPress?: (d: Destination) => void;
onClearRecent?: () => void;
}
const RECENT: RecentSearch[] = [
{ id: "1", place: "Japanese Tea Garden", detail: "Sep 12–14 · 2 guests" },
{ id: "2", place: "Half Moon Bay", detail: "Oct 3–5 · 4 guests" },
];
const DESTINATIONS: Destination[] = [
{ id: "1", name: "Cinque Terre", country: "Italy", price: 880, tag: "Explore", imageUrl: destImg("photo-1555881400-74d7acaacd8b") },
{ id: "2", name: "Kyoto", country: "Japan", price: 1180, tag: "Explore", imageUrl: destImg("photo-1493976040374-85c8e12f0c0e") },
{ id: "3", name: "Dolomites", country: "Italy", price: 640, tag: "Road Trip", imageUrl: destImg("photo-1489749798305-4fea3ae63d43") },
{ id: "4", name: "Point Reyes", country: "California", price: 95, tag: "Nearby", imageUrl: destImg("photo-1506905925346-21bda4d32df4") },
];
const FILTERS = ["All", "Nearby", "Explore", "Road Trip"] as const;
/** Travel discover — "Where can we take you?" idiom: headline + search +
* filter chips that actually filter the destination grid, dated recent
* searches with Clear, and a photo-card discovery grid. */
export function DiscoverScreen({
recent = RECENT,
destinations = DESTINATIONS,
onSearch,
onDestinationPress,
onClearRecent,
}: DiscoverScreenProps) {
const haptics = useHaptic();
const [filter, setFilter] = useState<(typeof FILTERS)[number]>("All");
const [recentList, setRecentList] = useState(recent);
const visible = destinations.filter((d) => filter === "All" || d.tag === filter);
return (
<World primary="#65A30D">
<SafeAreaView className="flex-1 bg-[#F4F5F9] dark:bg-[#111114]" edges={["top"]}>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerClassName="gap-6 px-6 pb-10 pt-3"
>
<Text variant="h2" accessibilityRole="header" style={{ fontFamily: SERIF }}>
Where can we{"\n"}<Text className="italic text-[#65A30D]">take you?</Text>
</Text>
<SearchInput
glass
placeholder="Search destinations"
onChangeText={(q) => onSearch?.(q)}
accessibilityLabel="Search destinations"
/>
{/* Wanderlust line — discovery feeds sell the season. */}
<Text variant="muted" className="-mt-2 text-[11px]">
6 places trending for October — shoulder season deals
</Text>
<Mount className="flex-row gap-2">
{FILTERS.map((f) => (
<Chip
key={f}
variant="filter"
selected={filter === f}
onPress={() => setFilter(f)}
accessibilityLabel={`Filter: ${f}`}
className={cn("h-10 px-4", filter === f && "border-[#65A30D] bg-[#65A30D]")}
textClassName={cn(filter === f && "text-primary-foreground")}
>
{f}
</Chip>
))}
</Mount>
{recentList.length > 0 ? (
<View className="gap-2">
<View className="flex-row items-center justify-between">
<Text variant="small" className="font-semibold">
Recent searches
</Text>
<Press
onPress={() => {
haptics.light();
setRecentList([]);
onClearRecent?.();
}}
accessibilityRole="button"
accessibilityLabel="Clear recent searches"
hitSlop={12}
className="min-h-11 justify-center"
>
<Text variant="small" className="text-[#65A30D]">
Clear
</Text>
</Press>
</View>
{recentList.map((r) => (
<Press
key={r.id}
onPress={() => haptics.selection()}
accessibilityRole="button"
accessibilityLabel={`${r.place}, ${r.detail}`}
className="flex-row items-center gap-3 rounded-xl py-1.5"
>
<View className="h-9 w-9 items-center justify-center rounded-full bg-muted">
<Icon name="clock" size="sm" color="mutedForeground" />
</View>
<View className="flex-1">
<Text variant="small" className="font-medium">
{r.place}
</Text>
<Text variant="muted" className="text-xs">
{r.detail}
</Text>
</View>
<Icon name="chevron-right" size="sm" color="mutedForeground" />
</Press>
))}
</View>
) : null}
<Mount delay={60} className="gap-3">
<Text variant="small" className="font-semibold">
{filter === "All" ? "Explore" : filter}
</Text>
<View className="flex-row flex-wrap justify-between gap-y-4">
{visible.map((d) => (
<Press
key={d.id}
onPress={() => {
haptics.selection();
onDestinationPress?.(d);
}}
accessibilityRole="button"
accessibilityLabel={`${d.name}, ${d.country}, from $${d.price}`}
className="w-[48%] overflow-hidden rounded-3xl"
>
<View>
<Image
source={{ uri: d.imageUrl }}
className="aspect-[4/5] w-full"
accessibilityElementsHidden
importantForAccessibility="no"
/>
<View className="absolute inset-x-0 bottom-0 h-16">
<StoryScrim edge="bottom" />
</View>
<View className="absolute inset-x-0 bottom-0 gap-0.5 p-3">
<Text className="font-semibold text-white">{d.name}</Text>
<Text className="text-xs text-white/80">
{d.country} · from ${d.price}
</Text>
</View>
</View>
</Press>
))}
</View>
</Mount>
</ScrollView>
</SafeAreaView>
</World>
);
}
DiscoverScreen.displayName = "DiscoverScreen";
Recreate the "Discover Screen" screen in React Native using LumioUI.
Goal: Travel discovery: search, filter chips, recent searches, photo-card destinations 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 travel-discover
```
Or pull just the components: `npx lumioui add chip 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
### `chip` — Compact selectable pill with press scale and ink overlay.
- `<Chip>` ×1
### `icon` — Lucide icon set mapped to theme colors and sizes.
- `<Icon>` ×2
### `image` — Cached image with placeholder, fallback and aspect handling.
- `<Image>` ×1
### `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>` ×10
## 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/travel/discover.tsx` exporting DiscoverScreen. 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 { useState } from "react";
import { Image, ScrollView, View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { Chip, Icon, SearchInput, Text } from "@lumioui/ui";
import { cn, useHaptic } from "@lumioui/core";
import { Mount, Press, SERIF, StoryScrim, World } from "../shared";
const destImg = (id: string) => `https://images.unsplash.com/${id}?w=600&h=750&fit=crop&q=70&auto=format`;
interface RecentSearch {
id: string;
place: string;
detail: string;
}
interface Destination {
id: string;
name: string;
country: string;
price: number;
tag: "Nearby" | "Explore" | "Road Trip";
imageUrl: string;
}
export interface DiscoverScreenProps {
recent?: RecentSearch[];
destinations?: Destination[];
onSearch?: (q: string) => void;
onDestinationPress?: (d: Destination) => void;
onClearRecent?: () => void;
}
const RECENT: RecentSearch[] = [
{ id: "1", place: "Japanese Tea Garden", detail: "Sep 12–14 · 2 guests" },
{ id: "2", place: "Half Moon Bay", detail: "Oct 3–5 · 4 guests" },
];
const DESTINATIONS: Destination[] = [
{ id: "1", name: "Cinque Terre", country: "Italy", price: 880, tag: "Explore", imageUrl: destImg("photo-1555881400-74d7acaacd8b") },
{ id: "2", name: "Kyoto", country: "Japan", price: 1180, tag: "Explore", imageUrl: destImg("photo-1493976040374-85c8e12f0c0e") },
{ id: "3", name: "Dolomites", country: "Italy", price: 640, tag: "Road Trip", imageUrl: destImg("photo-1489749798305-4fea3ae63d43") },
{ id: "4", name: "Point Reyes", country: "California", price: 95, tag: "Nearby", imageUrl: destImg("photo-1506905925346-21bda4d32df4") },
];
const FILTERS = ["All", "Nearby", "Explore", "Road Trip"] as const;
/** Travel discover — "Where can we take you?" idiom: headline + search +
* filter chips that actually filter the destination grid, dated recent
* searches with Clear, and a photo-card discovery grid. */
export function DiscoverScreen({
recent = RECENT,
destinations = DESTINATIONS,
onSearch,
onDestinationPress,
onClearRecent,
}: DiscoverScreenProps) {
const haptics = useHaptic();
const [filter, setFilter] = useState<(typeof FILTERS)[number]>("All");
const [recentList, setRecentList] = useState(recent);
const visible = destinations.filter((d) => filter === "All" || d.tag === filter);
return (
<World primary="#65A30D">
<SafeAreaView className="flex-1 bg-[#F4F5F9] dark:bg-[#111114]" edges={["top"]}>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerClassName="gap-6 px-6 pb-10 pt-3"
>
<Text variant="h2" accessibilityRole="header" style={{ fontFamily: SERIF }}>
Where can we{"\n"}<Text className="italic text-[#65A30D]">take you?</Text>
</Text>
<SearchInput
glass
placeholder="Search destinations"
onChangeText={(q) => onSearch?.(q)}
accessibilityLabel="Search destinations"
/>
{/* Wanderlust line — discovery feeds sell the season. */}
<Text variant="muted" className="-mt-2 text-[11px]">
6 places trending for October — shoulder season deals
</Text>
<Mount className="flex-row gap-2">
{FILTERS.map((f) => (
<Chip
key={f}
variant="filter"
selected={filter === f}
onPress={() => setFilter(f)}
accessibilityLabel={`Filter: ${f}`}
className={cn("h-10 px-4", filter === f && "border-[#65A30D] bg-[#65A30D]")}
textClassName={cn(filter === f && "text-primary-foreground")}
>
{f}
</Chip>
))}
</Mount>
{recentList.length > 0 ? (
<View className="gap-2">
<View className="flex-row items-center justify-between">
<Text variant="small" className="font-semibold">
Recent searches
</Text>
<Press
onPress={() => {
haptics.light();
setRecentList([]);
onClearRecent?.();
}}
accessibilityRole="button"
accessibilityLabel="Clear recent searches"
hitSlop={12}
className="min-h-11 justify-center"
>
<Text variant="small" className="text-[#65A30D]">
Clear
</Text>
</Press>
</View>
{recentList.map((r) => (
<Press
key={r.id}
onPress={() => haptics.selection()}
accessibilityRole="button"
accessibilityLabel={`${r.place}, ${r.detail}`}
className="flex-row items-center gap-3 rounded-xl py-1.5"
>
<View className="h-9 w-9 items-center justify-center rounded-full bg-muted">
<Icon name="clock" size="sm" color="mutedForeground" />
</View>
<View className="flex-1">
<Text variant="small" className="font-medium">
{r.place}
</Text>
<Text variant="muted" className="text-xs">
{r.detail}
</Text>
</View>
<Icon name="chevron-right" size="sm" color="mutedForeground" />
</Press>
))}
</View>
) : null}
<Mount delay={60} className="gap-3">
<Text variant="small" className="font-semibold">
{filter === "All" ? "Explore" : filter}
</Text>
<View className="flex-row flex-wrap justify-between gap-y-4">
{visible.map((d) => (
<Press
key={d.id}
onPress={() => {
haptics.selection();
onDestinationPress?.(d);
}}
accessibilityRole="button"
accessibilityLabel={`${d.name}, ${d.country}, from $${d.price}`}
className="w-[48%] overflow-hidden rounded-3xl"
>
<View>
<Image
source={{ uri: d.imageUrl }}
className="aspect-[4/5] w-full"
accessibilityElementsHidden
importantForAccessibility="no"
/>
<View className="absolute inset-x-0 bottom-0 h-16">
<StoryScrim edge="bottom" />
</View>
<View className="absolute inset-x-0 bottom-0 gap-0.5 p-3">
<Text className="font-semibold text-white">{d.name}</Text>
<Text className="text-xs text-white/80">
{d.country} · from ${d.price}
</Text>
</View>
</View>
</Press>
))}
</View>
</Mount>
</ScrollView>
</SafeAreaView>
</World>
);
}
DiscoverScreen.displayName = "DiscoverScreen";
```
Screen: https://lumioui.dev/screens/travel-discover