agents/openai.yaml
interface:
display_name: "Building Native UI"
short_description: "Build beautiful Expo Router UI with native-feeling navigation, controls, media, animation, and visual effects"
default_prompt: "Use $building-native-ui to build or refactor Expo Router UI, choose native-feeling navigation and controls, structure routes, style screens, and decide when Expo Go is enough before creating native builds."
references/animations.md
# Animations
Use Reanimated v4. Avoid React Native's built-in Animated API.
## Entering and Exiting Animations
Use Animated.View with entering and exiting animations. Layout animations can animate state changes.
```tsx
import Animated, {
FadeIn,
FadeOut,
LinearTransition,
} from "react-native-reanimated";
function App() {
return (
<Animated.View
entering={FadeIn}
exiting={FadeOut}
layout={LinearTransition}
/>
);
}
```
## On-Scroll Animations
Create high-performance scroll animations using Reanimated's hooks:
```tsx
import Animated, {
useAnimatedRef,
useScrollViewOffset,
useAnimatedStyle,
interpolate,
} from "react-native-reanimated";
function Page() {
const ref = useAnimatedRef();
const scroll = useScrollViewOffset(ref);
const style = useAnimatedStyle(() => ({
opacity: interpolate(scroll.value, [0, 30], [0, 1], "clamp"),
}));
return (
<Animated.ScrollView ref={ref}>
<Animated.View style={style} />
</Animated.ScrollView>
);
}
```
## Common Animation Presets
### Entering Animations
- `FadeIn`, `FadeInUp`, `FadeInDown`, `FadeInLeft`, `FadeInRight`
- `SlideInUp`, `SlideInDown`, `SlideInLeft`, `SlideInRight`
- `ZoomIn`, `ZoomInUp`, `ZoomInDown`
- `BounceIn`, `BounceInUp`, `BounceInDown`
### Exiting Animations
- `FadeOut`, `FadeOutUp`, `FadeOutDown`, `FadeOutLeft`, `FadeOutRight`
- `SlideOutUp`, `SlideOutDown`, `SlideOutLeft`, `SlideOutRight`
- `ZoomOut`, `ZoomOutUp`, `ZoomOutDown`
- `BounceOut`, `BounceOutUp`, `BounceOutDown`
### Layout Animations
- `LinearTransition` — Smooth linear interpolation
- `SequencedTransition` — Sequenced property changes
- `FadingTransition` — Fade between states
## Customizing Animations
```tsx
<Animated.View
entering={FadeInDown.duration(500).delay(200)}
exiting={FadeOut.duration(300)}
/>
```
### Modifiers
```tsx
// Duration in milliseconds
FadeIn.duration(300);
// Delay before starting
FadeIn.delay(100);
// Spring physics
FadeIn.springify();
FadeIn.springify().damping(15).stiffness(100);
// Easing curves
FadeIn.easing(Easing.bezier(0.25, 0.1, 0.25, 1));
// Chaining
FadeInDown.duration(400).delay(200).springify();
```
## Shared Value Animations
For imperative control over animations:
```tsx
import {
useSharedValue,
withSpring,
withTiming,
} from "react-native-reanimated";
const offset = useSharedValue(0);
// Spring animation
offset.value = withSpring(100);
// Timing animation
offset.value = withTiming(100, { duration: 300 });
// Use in styles
const style = useAnimatedStyle(() => ({
transform: [{ translateX: offset.value }],
}));
```
## Gesture Animations
Combine with React Native Gesture Handler:
```tsx
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
} from "react-native-reanimated";
function DraggableBox() {
const translateX = useSharedValue(0);
const translateY = useSharedValue(0);
const gesture = Gesture.Pan()
.onUpdate((e) => {
translateX.value = e.translationX;
translateY.value = e.translationY;
})
.onEnd(() => {
translateX.value = withSpring(0);
translateY.value = withSpring(0);
});
const style = useAnimatedStyle(() => ({
transform: [
{ translateX: translateX.value },
{ translateY: translateY.value },
],
}));
return (
<GestureDetector gesture={gesture}>
<Animated.View style={[styles.box, style]} />
</GestureDetector>
);
}
```
## Keyboard Animations
Animate with keyboard height changes:
```tsx
import Animated, {
useAnimatedKeyboard,
useAnimatedStyle,
} from "react-native-reanimated";
function KeyboardAwareView() {
const keyboard = useAnimatedKeyboard();
const style = useAnimatedStyle(() => ({
paddingBottom: keyboard.height.value,
}));
return <Animated.View style={style}>{/* content */}</Animated.View>;
}
```
## Staggered List Animations
Animate list items with delays:
```tsx
{
items.map((item, index) => (
<Animated.View
key={item.id}
entering={FadeInUp.delay(index * 50)}
exiting={FadeOutUp}
>
<ListItem item={item} />
</Animated.View>
));
}
```
## Best Practices
- Add entering and exiting animations for state changes
- Use layout animations when items are added/removed from lists
- Use `useAnimatedStyle` for scroll-driven animations
- Prefer `interpolate` with "clamp" for bounded values
- You can't pass `Color` (from expo-router) or `PlatformColor` values to reanimated views or styles; use static colors instead
- Keep animations under 300ms for responsive feel
- Use spring animations for natural movement
- Avoid animating layout properties (width, height) when possible — prefer transforms
references/controls.md
# Native Controls
Native iOS controls provide built-in haptics, accessibility, and platform-appropriate styling.
## Switch
Use for binary on/off settings. Has built-in haptics.
```tsx
import { Switch } from "react-native";
import { useState } from "react";
const [enabled, setEnabled] = useState(false);
<Switch value={enabled} onValueChange={setEnabled} />;
```
### Customization
```tsx
<Switch
value={enabled}
onValueChange={setEnabled}
trackColor={{ false: "#767577", true: "#81b0ff" }}
thumbColor={enabled ? "#f5dd4b" : "#f4f3f4"}
ios_backgroundColor="#3e3e3e"
/>
```
## Segmented Control
Use for non-navigational tabs or mode selection. Avoid changing default colors.
```tsx
import SegmentedControl from "@react-native-segmented-control/segmented-control";
import { useState } from "react";
const [index, setIndex] = useState(0);
<SegmentedControl
values={["All", "Active", "Done"]}
selectedIndex={index}
onChange={({ nativeEvent }) => setIndex(nativeEvent.selectedSegmentIndex)}
/>;
```
### Rules
- Maximum 4 options — use a picker for more
- Keep labels short (1-2 words)
- Avoid custom colors — native styling adapts to dark mode
### With Icons (iOS 14+)
```tsx
<SegmentedControl
values={[
{ label: "List", icon: "list.bullet" },
{ label: "Grid", icon: "square.grid.2x2" },
]}
selectedIndex={index}
onChange={({ nativeEvent }) => setIndex(nativeEvent.selectedSegmentIndex)}
/>
```
## Slider
Continuous value selection.
```tsx
import Slider from "@react-native-community/slider";
import { useState } from "react";
const [value, setValue] = useState(0.5);
<Slider
value={value}
onValueChange={setValue}
minimumValue={0}
maximumValue={1}
/>;
```
### Customization
```tsx
<Slider
value={value}
onValueChange={setValue}
minimumValue={0}
maximumValue={100}
step={1}
minimumTrackTintColor="#007AFF"
maximumTrackTintColor="#E5E5EA"
thumbTintColor="#007AFF"
/>
```
### Discrete Steps
```tsx
<Slider
value={value}
onValueChange={setValue}
minimumValue={0}
maximumValue={10}
step={1}
/>
```
## Date/Time Picker
Compact pickers with popovers. Has built-in haptics.
```tsx
import DateTimePicker from "@react-native-community/datetimepicker";
import { useState } from "react";
const [date, setDate] = useState(new Date());
<DateTimePicker
value={date}
onChange={(event, selectedDate) => {
if (selectedDate) setDate(selectedDate);
}}
mode="datetime"
/>;
```
### Modes
- `date` — Date only
- `time` — Time only
- `datetime` — Date and time
### Display Styles
```tsx
// Compact inline (default)
<DateTimePicker value={date} mode="date" />
// Spinner wheel
<DateTimePicker
value={date}
mode="date"
display="spinner"
style={{ width: 200, height: 150 }}
/>
// Full calendar
<DateTimePicker value={date} mode="date" display="inline" />
```
### Time Intervals
```tsx
<DateTimePicker
value={date}
mode="time"
minuteInterval={15}
/>
```
### Min/Max Dates
```tsx
<DateTimePicker
value={date}
mode="date"
minimumDate={new Date(2020, 0, 1)}
maximumDate={new Date(2030, 11, 31)}
/>
```
## Stepper
Increment/decrement numeric values.
```tsx
import { Stepper } from "react-native";
import { useState } from "react";
const [count, setCount] = useState(0);
<Stepper
value={count}
onValueChange={setCount}
minimumValue={0}
maximumValue={10}
/>;
```
## TextInput
Native text input with various keyboard types.
```tsx
import { TextInput } from "react-native";
<TextInput
placeholder="Enter text..."
placeholderTextColor="#999"
style={{
padding: 12,
fontSize: 16,
borderRadius: 8,
backgroundColor: "#f0f0f0",
}}
/>
```
### Keyboard Types
```tsx
// Email
<TextInput keyboardType="email-address" autoCapitalize="none" />
// Phone
<TextInput keyboardType="phone-pad" />
// Number
<TextInput keyboardType="numeric" />
// Password
<TextInput secureTextEntry />
// Search
<TextInput
returnKeyType="search"
enablesReturnKeyAutomatically
/>
```
### Multiline
```tsx
<TextInput
multiline
numberOfLines={4}
textAlignVertical="top"
style={{ minHeight: 100 }}
/>
```
## Picker (Wheel)
For selection from many options (5+ items).
```tsx
import { Picker } from "@react-native-picker/picker";
import { useState } from "react";
const [selected, setSelected] = useState("js");
<Picker selectedValue={selected} onValueChange={setSelected}>
<Picker.Item label="JavaScript" value="js" />
<Picker.Item label="TypeScript" value="ts" />
<Picker.Item label="Python" value="py" />
<Picker.Item label="Go" value="go" />
</Picker>;
```
## Best Practices
- **Haptics**: Switch and DateTimePicker have built-in haptics — don't add extra
- **Accessibility**: Native controls have proper accessibility labels by default
- **Dark Mode**: Avoid custom colors — native styling adapts automatically
- **Spacing**: Use consistent padding around controls (12-16pt)
- **Labels**: Place labels above or to the left of controls
- **Grouping**: Group related controls in sections with headers
references/gradients.md
# CSS Gradients
> **New Architecture Only**: CSS gradients require React Native's New Architecture (Fabric). They are not available in the old architecture or Expo Go.
Use CSS gradients with the `experimental_backgroundImage` style property.
## Linear Gradients
```tsx
// Top to bottom
<View style={{
experimental_backgroundImage: 'linear-gradient(to bottom, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 1) 100%)'
}} />
// Left to right
<View style={{
experimental_backgroundImage: 'linear-gradient(to right, #ff0000 0%, #0000ff 100%)'
}} />
// Diagonal
<View style={{
experimental_backgroundImage: 'linear-gradient(45deg, #ff0000 0%, #00ff00 50%, #0000ff 100%)'
}} />
// Using degrees
<View style={{
experimental_backgroundImage: 'linear-gradient(135deg, transparent 0%, black 100%)'
}} />
```
## Radial Gradients
```tsx
// Circle at center
<View style={{
experimental_backgroundImage: 'radial-gradient(circle at center, rgba(255, 0, 0, 1) 0%, rgba(0, 0, 255, 1) 100%)'
}} />
// Ellipse
<View style={{
experimental_backgroundImage: 'radial-gradient(ellipse at center, #fff 0%, #000 100%)'
}} />
// Positioned
<View style={{
experimental_backgroundImage: 'radial-gradient(circle at top left, #ff0000 0%, transparent 70%)'
}} />
```
## Multiple Gradients
Stack multiple gradients by comma-separating them:
```tsx
<View style={{
experimental_backgroundImage: `
linear-gradient(to bottom, transparent 0%, black 100%),
radial-gradient(circle at top right, rgba(255, 0, 0, 0.5) 0%, transparent 50%)
`
}} />
```
## Common Patterns
### Overlay on Image
```tsx
<View style={{ position: 'relative' }}>
<Image source={{ uri: '...' }} style={{ width: '100%', height: 200 }} />
<View style={{
position: 'absolute',
inset: 0,
experimental_backgroundImage: 'linear-gradient(to top, rgba(0, 0, 0, 0.8) 0%, transparent 50%)'
}} />
</View>
```
### Frosted Glass Effect
```tsx
<View style={{
experimental_backgroundImage: 'linear-gradient(135deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0.05) 100%)',
backdropFilter: 'blur(10px)',
}} />
```
### Button Gradient
```tsx
<Pressable style={{
experimental_backgroundImage: 'linear-gradient(to bottom, #4CAF50 0%, #388E3C 100%)',
padding: 16,
borderRadius: 8,
}}>
<Text style={{ color: 'white', textAlign: 'center' }}>Submit</Text>
</Pressable>
```
## Important Notes
- Do NOT use `expo-linear-gradient` — use CSS gradients instead
- Gradients are strings, not objects
- Use `rgba()` for transparency, or `transparent` keyword
- Color stops use percentages (0%, 50%, 100%)
- Direction keywords: `to top`, `to bottom`, `to left`, `to right`, `to top left`, etc.
- Degree values: `45deg`, `90deg`, `135deg`, etc.
references/icons.md
# Icons (SF Symbols)
Use SF Symbols for native feel. Never use FontAwesome or Ionicons.
## Basic Usage
```tsx
import { SymbolView } from "expo-symbols";
import { colors } from "@/theme/colors";
<SymbolView
tintColor={colors.label}
resizeMode="scaleAspectFit"
name="square.and.arrow.down"
style={{ width: 16, height: 16 }}
/>;
```
## Props
```tsx
<SymbolView
name="star.fill" // SF Symbol name (required)
tintColor={colors.label} // Icon color
size={24} // Shorthand for width/height
resizeMode="scaleAspectFit" // How to scale
weight="regular" // thin | ultraLight | light | regular | medium | semibold | bold | heavy | black
scale="medium" // small | medium | large
style={{ width: 16, height: 16 }} // Standard style props
/>
```
## Common Icons
### Navigation & Actions
- `house.fill` - home
- `gear` - settings
- `magnifyingglass` - search
- `plus` - add
- `xmark` - close
- `chevron.left` - back
- `chevron.right` - forward
- `arrow.left` - back arrow
- `arrow.right` - forward arrow
### Media
- `play.fill` - play
- `pause.fill` - pause
- `stop.fill` - stop
- `backward.fill` - rewind
- `forward.fill` - fast forward
- `speaker.wave.2.fill` - volume
- `speaker.slash.fill` - mute
### Camera
- `camera` - camera
- `camera.fill` - camera filled
- `arrow.triangle.2.circlepath` - flip camera
- `photo` - gallery/photos
- `bolt` - flash
- `bolt.slash` - flash off
### Communication
- `message` - message
- `message.fill` - message filled
- `envelope` - email
- `envelope.fill` - email filled
- `phone` - phone
- `phone.fill` - phone filled
- `video` - video call
- `video.fill` - video call filled
### Social
- `heart` - like
- `heart.fill` - liked
- `star` - favorite
- `star.fill` - favorited
- `hand.thumbsup` - thumbs up
- `hand.thumbsdown` - thumbs down
- `person` - profile
- `person.fill` - profile filled
- `person.2` - people
- `person.2.fill` - people filled
### Content Actions
- `square.and.arrow.up` - share
- `square.and.arrow.down` - download
- `doc.on.doc` - copy
- `trash` - delete
- `pencil` - edit
- `folder` - folder
- `folder.fill` - folder filled
- `bookmark` - bookmark
- `bookmark.fill` - bookmarked
### Status & Feedback
- `checkmark` - success/done
- `checkmark.circle.fill` - completed
- `xmark.circle.fill` - error/failed
- `exclamationmark.triangle` - warning
- `info.circle` - info
- `questionmark.circle` - help
- `bell` - notification
- `bell.fill` - notification filled
### Misc
- `ellipsis` - more options
- `ellipsis.circle` - more in circle
- `line.3.horizontal` - menu/hamburger
- `slider.horizontal.3` - filters
- `arrow.clockwise` - refresh
- `location` - location
- `location.fill` - location filled
- `map` - map
- `mappin` - pin
- `clock` - time
- `calendar` - calendar
- `link` - link
- `nosign` - block/prohibited
## Animated Symbols
```tsx
<SymbolView
name="checkmark.circle"
animationSpec={{
effect: {
type: "bounce",
direction: "up",
},
}}
/>
```
### Animation Effects
- `bounce` - Bouncy animation
- `pulse` - Pulsing effect
- `variableColor` - Color cycling
- `scale` - Scale animation
```tsx
// Bounce with direction
animationSpec={{
effect: { type: "bounce", direction: "up" } // up | down
}}
// Pulse
animationSpec={{
effect: { type: "pulse" }
}}
// Variable color (multicolor symbols)
animationSpec={{
effect: {
type: "variableColor",
cumulative: true,
reversing: true
}
}}
```
## Symbol Weights
```tsx
// Lighter weights
<SymbolView name="star" weight="ultraLight" />
<SymbolView name="star" weight="thin" />
<SymbolView name="star" weight="light" />
// Default
<SymbolView name="star" weight="regular" />
// Heavier weights
<SymbolView name="star" weight="medium" />
<SymbolView name="star" weight="semibold" />
<SymbolView name="star" weight="bold" />
<SymbolView name="star" weight="heavy" />
<SymbolView name="star" weight="black" />
```
## Symbol Scales
```tsx
<SymbolView name="star" scale="small" />
<SymbolView name="star" scale="medium" /> // default
<SymbolView name="star" scale="large" />
```
## Multicolor Symbols
Some symbols support multiple colors:
```tsx
<SymbolView
name="cloud.sun.rain.fill"
type="multicolor"
/>
```
## Finding Symbol Names
1. Use the SF Symbols app on macOS (free from Apple)
2. Search at https://developer.apple.com/sf-symbols/
3. Symbol names use dot notation: `square.and.arrow.up`
## Best Practices
- Always use SF Symbols over vector icon libraries
- Match symbol weight to nearby text weight
- Use `.fill` variants for selected/active states
- Use the cross-platform `colors` helper (see SKILL.md "Colors") for tint to support dark mode
- Keep icons at consistent sizes (16, 20, 24, 32)
references/media.md
# Media
## Camera
- Hide navigation headers when there's a full screen camera
- Ensure to flip the camera with `mirror` to emulate social apps
- Use liquid glass buttons on cameras
- Icons: `arrow.triangle.2.circlepath` (flip), `photo` (gallery), `bolt` (flash)
- Eagerly request camera permission
- Lazily request media library permission
```tsx
import React, { useRef, useState } from "react";
import { View, TouchableOpacity, Text, Alert } from "react-native";
import { CameraView, CameraType, useCameraPermissions } from "expo-camera";
import * as MediaLibrary from "expo-media-library";
import * as ImagePicker from "expo-image-picker";
import * as Haptics from "expo-haptics";
import { SymbolView } from "expo-symbols";
import { colors } from "@/theme/colors";
import { GlassView } from "expo-glass-effect";
import { useSafeAreaInsets } from "react-native-safe-area-context";
function Camera({ onPicture }: { onPicture: (uri: string) => Promise<void> }) {
const [permission, requestPermission] = useCameraPermissions();
const cameraRef = useRef<CameraView>(null);
const [type, setType] = useState<CameraType>("back");
const { bottom } = useSafeAreaInsets();
if (!permission?.granted) {
return (
<View style={{ flex: 1, justifyContent: "center", alignItems: "center", backgroundColor: colors.systemBackground }}>
<Text style={{ color: colors.label, padding: 16 }}>Camera access is required</Text>
<GlassView isInteractive tintColor={colors.systemBlue} style={{ borderRadius: 12 }}>
<TouchableOpacity onPress={requestPermission} style={{ padding: 12, borderRadius: 12 }}>
<Text style={{ color: "white" }}>Grant Permission</Text>
</TouchableOpacity>
</GlassView>
</View>
);
}
const takePhoto = async () => {
await Haptics.selectionAsync();
if (!cameraRef.current) return;
const photo = await cameraRef.current.takePictureAsync({ quality: 0.8 });
await onPicture(photo.uri);
};
const selectPhoto = async () => {
await Haptics.selectionAsync();
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: "images",
allowsEditing: false,
quality: 0.8,
});
if (!result.canceled && result.assets?.[0]) {
await onPicture(result.assets[0].uri);
}
};
return (
<View style={{ flex: 1, backgroundColor: "black" }}>
<CameraView ref={cameraRef} mirror style={{ flex: 1 }} facing={type} />
<View style={{ position: "absolute", left: 0, right: 0, bottom: bottom, gap: 16, alignItems: "center" }}>
<GlassView isInteractive style={{ padding: 8, borderRadius: 99 }}>
<TouchableOpacity onPress={takePhoto} style={{ width: 64, height: 64, borderRadius: 99, backgroundColor: "white" }} />
</GlassView>
<View style={{ flexDirection: "row", justifyContent: "space-around", paddingHorizontal: 8 }}>
<GlassButton onPress={selectPhoto} icon="photo" />
<GlassButton onPress={() => setType(t => t === "back" ? "front" : "back")} icon="arrow.triangle.2.circlepath" />
</View>
</View>
</View>
);
}
```
## Audio Playback
Use `expo-audio` not `expo-av`:
```tsx
import { useAudioPlayer } from 'expo-audio';
const player = useAudioPlayer({ uri: 'https://stream.nightride.fm/rektory.mp3' });
<Button title="Play" onPress={() => player.play()} />
```
## Audio Recording (Microphone)
```tsx
import {
useAudioRecorder,
AudioModule,
RecordingPresets,
setAudioModeAsync,
useAudioRecorderState,
} from 'expo-audio';
import { useEffect } from 'react';
import { Alert, Button } from 'react-native';
function App() {
const audioRecorder = useAudioRecorder(RecordingPresets.HIGH_QUALITY);
const recorderState = useAudioRecorderState(audioRecorder);
const record = async () => {
await audioRecorder.prepareToRecordAsync();
audioRecorder.record();
};
const stop = () => audioRecorder.stop();
useEffect(() => {
(async () => {
const status = await AudioModule.requestRecordingPermissionsAsync();
if (status.granted) {
setAudioModeAsync({ playsInSilentMode: true, allowsRecording: true });
} else {
Alert.alert('Permission to access microphone was denied');
}
})();
}, []);
return (
<Button
title={recorderState.isRecording ? 'Stop' : 'Start'}
onPress={recorderState.isRecording ? stop : record}
/>
);
}
```
## Video Playback
Use `expo-video` not `expo-av`:
```tsx
import { useVideoPlayer, VideoView } from 'expo-video';
import { useEvent } from 'expo';
const videoSource = 'https://example.com/video.mp4';
const player = useVideoPlayer(videoSource, player => {
player.loop = true;
player.play();
});
const { isPlaying } = useEvent(player, 'playingChange', { isPlaying: player.playing });
<VideoView player={player} fullscreenOptions={{}} allowsPictureInPicture />
```
VideoView options:
- `allowsPictureInPicture`: boolean
- `contentFit`: 'contain' | 'cover' | 'fill'
- `nativeControls`: boolean
- `playsInline`: boolean
- `startsPictureInPictureAutomatically`: boolean
## Saving Media
```tsx
import * as MediaLibrary from "expo-media-library";
const { granted } = await MediaLibrary.requestPermissionsAsync();
if (granted) {
await MediaLibrary.saveToLibraryAsync(uri);
}
```
### Saving Base64 Images
`MediaLibrary.saveToLibraryAsync` only accepts local file paths. Save base64 strings to disk first:
```tsx
import { File, Paths } from "expo-file-system/next";
function base64ToLocalUri(base64: string, filename?: string) {
if (!filename) {
const match = base64.match(/^data:(image\/[a-zA-Z]+);base64,/);
const ext = match ? match[1].split("/")[1] : "jpg";
filename = `generated-${Date.now()}.${ext}`;
}
if (base64.startsWith("data:")) base64 = base64.split(",")[1];
const binaryString = atob(base64);
const len = binaryString.length;
const bytes = new Uint8Array(new ArrayBuffer(len));
for (let i = 0; i < len; i++) bytes[i] = binaryString.charCodeAt(i);
const f = new File(Paths.cache, filename);
f.create({ overwrite: true });
f.write(bytes);
return f.uri;
}
```
references/storage.md
# Storage
## Key-Value Storage
Use the localStorage polyfill for key-value storage. **Never use AsyncStorage**
```tsx
import "expo-sqlite/localStorage/install";
// Simple get/set
localStorage.setItem("key", "value");
localStorage.getItem("key");
// Store objects as JSON
localStorage.setItem("user", JSON.stringify({ name: "John", id: 1 }));
const user = JSON.parse(localStorage.getItem("user") ?? "{}");
```
## When to Use What
| Use Case | Solution |
| ---------------------------------------------------- | ----------------------- |
| Simple key-value (settings, preferences, small data) | `localStorage` polyfill |
| Large datasets, complex queries, relational data | Full `expo-sqlite` |
| Sensitive data (tokens, passwords) | `expo-secure-store` |
## Storage with React State
Create a storage utility with subscriptions for reactive updates:
```tsx
// utils/storage.ts
import "expo-sqlite/localStorage/install";
type Listener = () => void;
const listeners = new Map<string, Set<Listener>>();
export const storage = {
get<T>(key: string, defaultValue: T): T {
const value = localStorage.getItem(key);
return value ? JSON.parse(value) : defaultValue;
},
set<T>(key: string, value: T): void {
localStorage.setItem(key, JSON.stringify(value));
listeners.get(key)?.forEach((fn) => fn());
},
subscribe(key: string, listener: Listener): () => void {
if (!listeners.has(key)) listeners.set(key, new Set());
listeners.get(key)!.add(listener);
return () => listeners.get(key)?.delete(listener);
},
};
```
## React Hook for Storage
```tsx
// hooks/use-storage.ts
import { useSyncExternalStore } from "react";
import { storage } from "@/utils/storage";
export function useStorage<T>(
key: string,
defaultValue: T
): [T, (value: T) => void] {
const value = useSyncExternalStore(
(cb) => storage.subscribe(key, cb),
() => storage.get(key, defaultValue)
);
return [value, (newValue: T) => storage.set(key, newValue)];
}
```
Usage:
```tsx
function Settings() {
const [theme, setTheme] = useStorage("theme", "light");
return (
<Switch
value={theme === "dark"}
onValueChange={(dark) => setTheme(dark ? "dark" : "light")}
/>
);
}
```
## Full SQLite for Complex Data
For larger datasets or complex queries, use expo-sqlite directly:
```tsx
import * as SQLite from "expo-sqlite";
const db = await SQLite.openDatabaseAsync("app.db");
// Create table
await db.execAsync(`
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
date TEXT NOT NULL,
location TEXT
)
`);
// Insert
await db.runAsync("INSERT INTO events (title, date) VALUES (?, ?)", [
"Meeting",
"2024-01-15",
]);
// Query
const events = await db.getAllAsync("SELECT * FROM events WHERE date > ?", [
"2024-01-01",
]);
```
references/visual-effects.md
# Visual Effects
## Backdrop Blur
Use `expo-blur` for blur effects. Prefer systemMaterial tints as they adapt to dark mode.
```tsx
import { BlurView } from "expo-blur";
<BlurView tint="systemMaterial" intensity={100} />;
```
### Tint Options
```tsx
// System materials (adapt to dark mode)
<BlurView tint="systemMaterial" />
<BlurView tint="systemThinMaterial" />
<BlurView tint="systemUltraThinMaterial" />
<BlurView tint="systemThickMaterial" />
<BlurView tint="systemChromeMaterial" />
// Basic tints
<BlurView tint="light" />
<BlurView tint="dark" />
<BlurView tint="default" />
// Prominent (more visible)
<BlurView tint="prominent" />
// Extra light/dark
<BlurView tint="extraLight" />
```
### Intensity
Control blur strength with `intensity` (0-100):
```tsx
<BlurView tint="systemMaterial" intensity={50} /> // Subtle
<BlurView tint="systemMaterial" intensity={100} /> // Full
```
### Rounded Corners
BlurView requires `overflow: 'hidden'` to clip rounded corners:
```tsx
<BlurView
tint="systemMaterial"
intensity={100}
style={{
borderRadius: 16,
overflow: 'hidden',
}}
/>
```
### Overlay Pattern
Common pattern for overlaying blur on content:
```tsx
<View style={{ position: 'relative' }}>
<Image source={{ uri: '...' }} style={{ width: '100%', height: 200 }} />
<BlurView
tint="systemUltraThinMaterial"
intensity={80}
style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
padding: 16,
}}
>
<Text style={{ color: 'white' }}>Caption</Text>
</BlurView>
</View>
```
## Glass Effects (iOS 26+)
Use `expo-glass-effect` for liquid glass backdrops on iOS 26+.
```tsx
import { GlassView } from "expo-glass-effect";
<GlassView style={{ borderRadius: 16, padding: 16 }}>
<Text>Content inside glass</Text>
</GlassView>
```
### Interactive Glass
Add `isInteractive` for buttons and pressable glass:
```tsx
import { GlassView } from "expo-glass-effect";
import { SymbolView } from "expo-symbols";
import { colors } from "@/theme/colors";
<GlassView isInteractive style={{ borderRadius: 50 }}>
<Pressable style={{ padding: 12 }} onPress={handlePress}>
<SymbolView name="plus" tintColor={colors.label} size={36} />
</Pressable>
</GlassView>
```
### Glass Buttons
Create liquid glass buttons:
```tsx
function GlassButton({ icon, onPress }) {
return (
<GlassView isInteractive style={{ borderRadius: 50 }}>
<Pressable style={{ padding: 12 }} onPress={onPress}>
<SymbolView name={icon} tintColor={colors.label} size={24} />
</Pressable>
</GlassView>
);
}
// Usage
<GlassButton icon="plus" onPress={handleAdd} />
<GlassButton icon="gear" onPress={handleSettings} />
```
### Glass Card
```tsx
<GlassView style={{ borderRadius: 20, padding: 20 }}>
<Text style={{ fontSize: 18, fontWeight: '600', color: colors.label }}>
Card Title
</Text>
<Text style={{ color: colors.secondaryLabel, marginTop: 8 }}>
Card content goes here
</Text>
</GlassView>
```
### Checking Availability
```tsx
import { isLiquidGlassAvailable } from "expo-glass-effect";
if (isLiquidGlassAvailable()) {
// Use GlassView
} else {
// Fallback to BlurView or solid background
}
```
### Fallback Pattern
```tsx
import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect";
import { BlurView } from "expo-blur";
function AdaptiveGlass({ children, style }) {
if (isLiquidGlassAvailable()) {
return <GlassView style={style}>{children}</GlassView>;
}
return (
<BlurView tint="systemMaterial" intensity={80} style={style}>
{children}
</BlurView>
);
}
```
## Sheet with Glass Background
Make sheet backgrounds liquid glass on iOS 26+:
```tsx
<Stack.Screen
name="sheet"
options={{
presentation: "formSheet",
sheetGrabberVisible: true,
sheetAllowedDetents: [0.5, 1.0],
contentStyle: { backgroundColor: "transparent" },
}}
/>
```
## Best Practices
- Use `systemMaterial` tints for automatic dark mode support
- Always set `overflow: 'hidden'` on BlurView for rounded corners
- Use `isInteractive` on GlassView for buttons and pressables
- Check `isLiquidGlassAvailable()` and provide fallbacks
- Avoid nesting blur views (performance impact)
- Keep blur intensity reasonable (50-100) for readability
references/webgpu-three.md
# WebGPU & Three.js for Expo
**Use this skill for ANY 3D graphics, games, GPU compute, or Three.js features in React Native.**
## Locked Versions (Tested & Working)
```json
{
"react-native-wgpu": "^0.4.1",
"three": "0.172.0",
"@react-three/fiber": "^9.4.0",
"wgpu-matrix": "^3.0.2",
"@types/three": "0.172.0"
}
```
**Critical:** These versions are tested together. Mismatched versions cause type errors and runtime issues.
## Installation
```bash
npm install react-native-wgpu@^0.4.1 three@0.172.0 @react-three/fiber@^9.4.0 wgpu-matrix@^3.0.2 @types/three@0.172.0 --legacy-peer-deps
```
**Note:** `--legacy-peer-deps` may be required due to peer dependency conflicts with canary Expo versions.
## Metro Configuration
Create `metro.config.js` in project root:
```js
const { getDefaultConfig } = require("expo/metro-config");
const config = getDefaultConfig(__dirname);
config.resolver.resolveRequest = (context, moduleName, platform) => {
// Force 'three' to webgpu build
if (moduleName.startsWith("three")) {
moduleName = "three/webgpu";
}
// Use standard react-three/fiber instead of React Native version
if (platform !== "web" && moduleName.startsWith("@react-three/fiber")) {
return context.resolveRequest(
{
...context,
unstable_conditionNames: ["module"],
mainFields: ["module"],
},
moduleName,
platform
);
}
return context.resolveRequest(context, moduleName, platform);
};
module.exports = config;
```
## Required Lib Files
Create these files in `src/lib/`:
### 1. make-webgpu-renderer.ts
```ts
import type { NativeCanvas } from "react-native-wgpu";
import * as THREE from "three/webgpu";
export class ReactNativeCanvas {
constructor(private canvas: NativeCanvas) {}
get width() {
return this.canvas.width;
}
get height() {
return this.canvas.height;
}
set width(width: number) {
this.canvas.width = width;
}
set height(height: number) {
this.canvas.height = height;
}
get clientWidth() {
return this.canvas.width;
}
get clientHeight() {
return this.canvas.height;
}
set clientWidth(width: number) {
this.canvas.width = width;
}
set clientHeight(height: number) {
this.canvas.height = height;
}
addEventListener(_type: string, _listener: EventListener) {}
removeEventListener(_type: string, _listener: EventListener) {}
dispatchEvent(_event: Event) {}
setPointerCapture() {}
releasePointerCapture() {}
}
export const makeWebGPURenderer = (
context: GPUCanvasContext,
{ antialias = true }: { antialias?: boolean } = {}
) =>
new THREE.WebGPURenderer({
antialias,
// @ts-expect-error
canvas: new ReactNativeCanvas(context.canvas),
context,
});
```
### 2. fiber-canvas.tsx
```tsx
import * as THREE from "three/webgpu";
import React, { useEffect, useRef } from "react";
import type { ReconcilerRoot, RootState } from "@react-three/fiber";
import {
extend,
createRoot,
unmountComponentAtNode,
events,
} from "@react-three/fiber";
import type { ViewProps } from "react-native";
import { PixelRatio } from "react-native";
import { Canvas, type CanvasRef } from "react-native-wgpu";
import {
makeWebGPURenderer,
ReactNativeCanvas,
} from "@/lib/make-webgpu-renderer";
// Extend THREE namespace for R3F - add all components you use
extend({
AmbientLight: THREE.AmbientLight,
DirectionalLight: THREE.DirectionalLight,
PointLight: THREE.PointLight,
SpotLight: THREE.SpotLight,
Mesh: THREE.Mesh,
Group: THREE.Group,
Points: THREE.Points,
BoxGeometry: THREE.BoxGeometry,
SphereGeometry: THREE.SphereGeometry,
CylinderGeometry: THREE.CylinderGeometry,
ConeGeometry: THREE.ConeGeometry,
DodecahedronGeometry: THREE.DodecahedronGeometry,
BufferGeometry: THREE.BufferGeometry,
BufferAttribute: THREE.BufferAttribute,
MeshStandardMaterial: THREE.MeshStandardMaterial,
MeshBasicMaterial: THREE.MeshBasicMaterial,
PointsMaterial: THREE.PointsMaterial,
PerspectiveCamera: THREE.PerspectiveCamera,
Scene: THREE.Scene,
});
interface FiberCanvasProps {
children: React.ReactNode;
style?: ViewProps["style"];
camera?: THREE.PerspectiveCamera;
scene?: THREE.Scene;
}
export const FiberCanvas = ({
children,
style,
scene,
camera,
}: FiberCanvasProps) => {
const root = useRef<ReconcilerRoot<OffscreenCanvas>>(null!);
const canvasRef = useRef<CanvasRef>(null);
useEffect(() => {
const context = canvasRef.current!.getContext("webgpu")!;
const renderer = makeWebGPURenderer(context);
// @ts-expect-error - ReactNativeCanvas wraps native canvas
const canvas = new ReactNativeCanvas(context.canvas) as HTMLCanvasElement;
canvas.width = canvas.clientWidth * PixelRatio.get();
canvas.height = canvas.clientHeight * PixelRatio.get();
const size = {
top: 0,
left: 0,
width: canvas.clientWidth,
height: canvas.clientHeight,
};
if (!root.current) {
root.current = createRoot(canvas);
}
root.current.configure({
size,
events,
scene,
camera,
gl: renderer,
frameloop: "always",
dpr: 1,
onCreated: async (state: RootState) => {
// @ts-expect-error - WebGPU renderer has init method
await state.gl.init();
const renderFrame = state.gl.render.bind(state.gl);
state.gl.render = (s: THREE.Scene, c: THREE.Camera) => {
renderFrame(s, c);
context?.present();
};
},
});
root.current.render(children);
return () => {
if (canvas != null) {
unmountComponentAtNode(canvas!);
}
};
});
return <Canvas ref={canvasRef} style={style} />;
};
```
## Basic 3D Scene
```tsx
import * as THREE from "three/webgpu";
import { View } from "react-native";
import { useRef } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import { FiberCanvas } from "@/lib/fiber-canvas";
function RotatingBox() {
const ref = useRef<THREE.Mesh>(null!);
useFrame((_, delta) => {
ref.current.rotation.x += delta;
ref.current.rotation.y += delta * 0.5;
});
return (
<mesh ref={ref}>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color="hotpink" />
</mesh>
);
}
function Scene() {
const { camera } = useThree();
useEffect(() => {
camera.position.set(0, 2, 5);
camera.lookAt(0, 0, 0);
}, [camera]);
return (
<>
<ambientLight intensity={0.5} />
<directionalLight position={[10, 10, 5]} intensity={1} />
<RotatingBox />
</>
);
}
export default function App() {
return (
<View style={{ flex: 1 }}>
<FiberCanvas style={{ flex: 1 }}>
<Scene />
</FiberCanvas>
</View>
);
}
```
## Lazy Loading (Recommended)
Use React.lazy to code-split Three.js for better loading:
```tsx
import React, { Suspense } from "react";
import { ActivityIndicator, View } from "react-native";
const Scene = React.lazy(() => import("@/components/scene"));
export default function Page() {
return (
<View style={{ flex: 1 }}>
<Suspense fallback={<ActivityIndicator size="large" />}>
<Scene />
</Suspense>
</View>
);
}
```
## Common Geometries
```tsx
// Box
<mesh>
<boxGeometry args={[width, height, depth]} />
<meshStandardMaterial color="red" />
</mesh>
// Sphere
<mesh>
<sphereGeometry args={[radius, widthSegments, heightSegments]} />
<meshStandardMaterial color="blue" />
</mesh>
// Cylinder
<mesh>
<cylinderGeometry args={[radiusTop, radiusBottom, height, segments]} />
<meshStandardMaterial color="green" />
</mesh>
// Cone
<mesh>
<coneGeometry args={[radius, height, segments]} />
<meshStandardMaterial color="yellow" />
</mesh>
```
## Lighting
```tsx
// Ambient (uniform light everywhere)
<ambientLight intensity={0.5} />
// Directional (sun-like)
<directionalLight position={[10, 10, 5]} intensity={1} />
// Point (light bulb)
<pointLight position={[0, 5, 0]} intensity={2} distance={10} />
// Spot (flashlight)
<spotLight position={[0, 10, 0]} angle={0.3} penumbra={1} intensity={2} />
```
## Animation with useFrame
```tsx
import { useFrame } from "@react-three/fiber";
import { useRef } from "react";
import * as THREE from "three/webgpu";
function AnimatedMesh() {
const ref = useRef<THREE.Mesh>(null!);
// Runs every frame - delta is time since last frame
useFrame((state, delta) => {
// Rotate
ref.current.rotation.y += delta;
// Oscillate position
ref.current.position.y = Math.sin(state.clock.elapsedTime) * 2;
});
return (
<mesh ref={ref}>
<boxGeometry />
<meshStandardMaterial color="orange" />
</mesh>
);
}
```
## Particle Systems
```tsx
import * as THREE from "three/webgpu";
import { useRef, useEffect } from "react";
import { useFrame } from "@react-three/fiber";
function Particles({ count = 500 }) {
const ref = useRef<THREE.Points>(null!);
const positions = useRef<Float32Array>(new Float32Array(count * 3));
useEffect(() => {
for (let i = 0; i < count; i++) {
positions.current[i * 3] = (Math.random() - 0.5) * 50;
positions.current[i * 3 + 1] = (Math.random() - 0.5) * 50;
positions.current[i * 3 + 2] = (Math.random() - 0.5) * 50;
}
}, [count]);
useFrame((_, delta) => {
// Animate particles
for (let i = 0; i < count; i++) {
positions.current[i * 3 + 1] -= delta * 2;
if (positions.current[i * 3 + 1] < -25) {
positions.current[i * 3 + 1] = 25;
}
}
ref.current.geometry.attributes.position.needsUpdate = true;
});
return (
<points ref={ref}>
<bufferGeometry>
<bufferAttribute
attach="attributes-position"
args={[positions.current, 3]}
/>
</bufferGeometry>
<pointsMaterial color="#ffffff" size={0.2} sizeAttenuation />
</points>
);
}
```
## Touch Controls (Orbit)
See the full `orbit-controls.tsx` implementation in the lib files. Usage:
```tsx
import { View } from "react-native";
import { FiberCanvas } from "@/lib/fiber-canvas";
import useControls from "@/lib/orbit-controls";
function Scene() {
const [OrbitControls, events] = useControls();
return (
<View style={{ flex: 1 }} {...events}>
<FiberCanvas style={{ flex: 1 }}>
<OrbitControls />
{/* Your 3D content */}
</FiberCanvas>
</View>
);
}
```
## Common Issues & Solutions
### 1. "X is not part of the THREE namespace"
**Problem:** Error like `AmbientLight is not part of the THREE namespace`
**Solution:** Add the missing component to the `extend()` call in fiber-canvas.tsx:
```tsx
extend({
AmbientLight: THREE.AmbientLight,
// Add other missing components...
});
```
### 2. TypeScript Errors with Three.js
**Problem:** Type mismatches between three.js and R3F
**Solution:** Use `@ts-expect-error` comments where needed:
```tsx
// @ts-expect-error - WebGPU renderer types don't match
await state.gl.init();
```
### 3. Blank Screen
**Problem:** Canvas renders but nothing visible
**Solution:**
1. Ensure camera is positioned correctly and looking at scene
2. Add lighting (objects are black without light)
3. Check that `extend()` includes all components used
### 4. Performance Issues
**Problem:** Low frame rate or stuttering
**Solution:**
- Reduce polygon count in geometries
- Use `useMemo` for static data
- Limit particle count
- Use `instancedMesh` for many identical objects
### 5. Peer Dependency Errors
**Problem:** npm install fails with ERESOLVE
**Solution:** Use `--legacy-peer-deps`:
```bash
npm install <packages> --legacy-peer-deps
```
## Building
WebGPU requires a custom build:
```bash
npx expo prebuild
npx expo run:ios
```
**Note:** WebGPU does NOT work in Expo Go.
## File Structure
```
src/
├── app/
│ └── index.tsx # Entry point with lazy loading
├── components/
│ ├── scene.tsx # Main 3D scene
│ └── game.tsx # Game logic
└── lib/
├── fiber-canvas.tsx # R3F canvas wrapper
├── make-webgpu-renderer.ts # WebGPU renderer
└── orbit-controls.tsx # Touch controls
```
## Decision Tree
```
Need 3D graphics?
├── Simple shapes → mesh + geometry + material
├── Animated objects → useFrame + refs
├── Many objects → instancedMesh
├── Particles → Points + BufferGeometry
│
Need interaction?
├── Orbit camera → useControls hook
├── Touch objects → onClick on mesh
├── Gestures → react-native-gesture-handler
│
Performance critical?
├── Static geometry → useMemo
├── Many instances → InstancedMesh
└── Complex scenes → LOD (Level of Detail)
```
## Example: Complete Game Scene
```tsx
import * as THREE from "three/webgpu";
import { View, Text, Pressable } from "react-native";
import { useRef, useState, useCallback } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import { FiberCanvas } from "@/lib/fiber-canvas";
function Player({ position }: { position: THREE.Vector3 }) {
const ref = useRef<THREE.Mesh>(null!);
useFrame(() => {
ref.current.position.copy(position);
});
return (
<mesh ref={ref}>
<coneGeometry args={[0.5, 1, 8]} />
<meshStandardMaterial color="#00ffff" />
</mesh>
);
}
function GameScene({ playerX }: { playerX: number }) {
const { camera } = useThree();
const playerPos = useRef(new THREE.Vector3(0, 0, 0));
playerPos.current.x = playerX;
useEffect(() => {
camera.position.set(0, 10, 15);
camera.lookAt(0, 0, 0);
}, [camera]);
return (
<>
<ambientLight intensity={0.5} />
<directionalLight position={[5, 10, 5]} />
<Player position={playerPos.current} />
</>
);
}
export default function Game() {
const [playerX, setPlayerX] = useState(0);
return (
<View style={{ flex: 1, backgroundColor: "#000" }}>
<FiberCanvas style={{ flex: 1 }}>
<GameScene playerX={playerX} />
</FiberCanvas>
<View style={{ position: "absolute", bottom: 40, flexDirection: "row" }}>
<Pressable onPress={() => setPlayerX((x) => x - 1)}>
<Text style={{ color: "#fff", fontSize: 32 }}>◀</Text>
</Pressable>
<Pressable onPress={() => setPlayerX((x) => x + 1)}>
<Text style={{ color: "#fff", fontSize: 32 }}>▶</Text>
</Pressable>
</View>
</View>
);
}
```