expo-native-ui
Build beautiful, native-feeling Expo screens. Covers Apple HIG styling, semantic colors, native controls, SF Symbols, media, visual effects, gradients, storage, and responsive layout. For routing and navigation, use the expo-router skill; for motion and animation, use the expo-animation skill.
Expo Native UI Guidelines
For routes, links, stacks, tabs, modals, sheets, and headers, use the expo-router skill. For any motion — entering/exiting, gestures, springs, keyboard-driven UI — use the expo-animation skill.
Before picking any UI component, check
expo-uifirst.@expo/uiprovides native equivalents — BottomSheet, Button, Picker, Slider, Menu, Section, Switch, SegmentedControl, and more — rendered as real SwiftUI on iOS and Jetpack Compose on Android, available in Expo Go on SDK 56+ with no custom build. Load theexpo-uiskill to find the right component before falling back to React Native built-ins or community libraries. This skill (expo-native-ui) covers the surrounding structure: Expo Router navigation, layout, styling, and visual effects.
References
Consult these resources as needed:
references/
controls.md Native iOS: Switch, Slider, SegmentedControl, DateTimePicker, Picker
gradients.md CSS gradients via experimental_backgroundImage (New Arch only)
icons.md SF Symbols via expo-symbols SymbolView: names, weights, animations; Material icons on Android
media.md Camera, audio, video, and file saving
storage.md SQLite, AsyncStorage, SecureStore
visual-effects.md Blur (expo-blur) and liquid glass (expo-glass-effect)
webgpu-three.md 3D graphics, games, GPU visualizations with WebGPU and Three.jsRunning the App
CRITICAL: Always try Expo Go first before creating custom builds.
Most Expo apps work in Expo Go without any custom native code. Before running npx expo run:ios or npx expo run:android:
- Start with Expo Go: Run
npx expo startand scan the QR code with Expo Go - Check if features work: Test your app thoroughly in Expo Go
- Only create custom builds when required - see below
When Custom Builds Are Required
You need npx expo run:ios/android or eas build ONLY when using:
- Local Expo modules (custom native code in
modules/) - Apple targets (widgets, app clips, extensions via
@bacons/apple-targets) - Third-party native modules not included in Expo Go
- Custom native configuration that can't be expressed in
app.json
When Expo Go Works
Expo Go supports a wide range of features out of the box:
- Most
expo-*packages (camera, location, sensors, sqlite, etc.) — but not all: remote push notifications don't work in Expo Go on Android since SDK 53, and some packages need native capabilities Expo Go doesn't bundle (e.g. WebGPU — seereferences/webgpu-three.md) - Expo Router navigation and deep links
- Most UI libraries (reanimated, gesture handler, etc.)
If you're unsure, try Expo Go first. Creating custom builds adds complexity, slower iteration, and requires Xcode/Android Studio setup.
Code Style
- Be cautious of unterminated strings. Ensure nested backticks are escaped; never forget to escape quotes correctly.
- Always use import statements at the top of the file.
- Always use kebab-case for file names, e.g.
comment-card.tsx - Never use special characters in file names
- Configure tsconfig.json with path aliases, and prefer aliases over relative imports for refactors.
Library Preferences
- For any sheet, picker, slider, toggle, menu, or grouped-form section: use
@expo/ui(seeexpo-uiskill) before reaching for a React Native built-in or community library — it renders native SwiftUI/Compose and works in Expo Go on SDK 56+. For grouped/settings-style rows (short, fixed-length), use@expo/ui'sList+ListItem. For large or unknown-length scrolling lists (feeds, search results, catalogs), useFlatListorFlashList—@expo/ui'sListis not virtualized. - Never use modules removed from React Native such as Picker, WebView, SafeAreaView, or AsyncStorage
- Never use legacy expo-permissions
expo-audionotexpo-avexpo-videonotexpo-avexpo-symbols(SymbolView) for SF Symbols on iOS, not@expo/vector-icons— seereferences/icons.md. SF Symbols are Apple-only: on Android every icon needs a Material source (mdprop on NativeTabs triggers; in-screen options under "Android: Material Icons" in icons.md), never SF-only iconographyreact-native-safe-area-contextnot react-native SafeAreaViewprocess.env.EXPO_OSnotPlatform.OSReact.usenotReact.useContextexpo-imageImage component instead of intrinsic elementimgexpo-glass-effectfor liquid glass backdropsColorfromexpo-routerfor native semantic colors, not rawPlatformColor(type-safe, auto-adapts to light/dark)- In SDK 56+, never import from
@react-navigation/*directly — useexpo-router/react-navigationinstead (covers@react-navigation/native,/core,/elements,/routers)
Responsiveness
- Wrap screens with scrollable content in a ScrollView. Screens whose root is a FlatList/FlashList must not add an outer ScrollView (the list is the scroll container), and full-bleed screens (camera, map, canvas) need neither
- Use
<ScrollView contentInsetAdjustmentBehavior="automatic" />instead of<SafeAreaView>for smarter safe area insets contentInsetAdjustmentBehavior="automatic"should be applied to FlatList and SectionList as well- Use flexbox instead of Dimensions API
- ALWAYS prefer
useWindowDimensionsoverDimensions.get()to measure screen size
Behavior
- Use expo-haptics conditionally on iOS to make more delightful experiences
- Use views with built-in haptics like
<Switch />from React Native and@react-native-community/datetimepicker - When a Stack route has scrollable content, make the ScrollView (or FlatList) the first component inside the route, with
contentInsetAdjustmentBehavior="automatic"set - Use the
<Text selectable />prop on text containing data that could be copied - Consider formatting large numbers like 1.4M or 38k
- Never use intrinsic elements like 'img' or 'div' unless in a webview or Expo DOM component
- Every screen that loads data has four states (loading, error, empty, content) - never show the empty state while the first load is still resolving; the rules live in the
expo-data-fetchingskill - On scrollable forms and search results, use
keyboardShouldPersistTaps="handled"so controls receive the first tap and unhandled taps can dismiss the keyboard. Use"always"only when unhandled taps should also keep it open - A form's primary action must never sit under the keyboard. For UI that tracks the keyboard's real frame, load the
expo-animationskill's keyboard recipe (react-native-keyboard-controller) - neverKeyboard.addListenerplus a timing animation - Every enabled control must perform its advertised action: search filters results, Save commits edits, and settings affect behavior. Empty handlers and success alerts are not implementations; local state is enough when the user requested a prototype
- For async saves, preserve drafts and handle pending/failure states per
expo-data-fetching; do not dismiss a form before its save succeeds
Before calling a screen complete, walk through its primary task, including one failure and recovery when it loads or saves data. Check keyboard access and back/dismiss behavior. Try long titles, missing images, no search results, and large system text; required actions must remain reachable. Report what you exercised and what you could not run.
Styling
Follow each platform's own design language: Apple Human Interface Guidelines on iOS, Material Design 3 on Android. Never dress one platform in the other's uniform - no FAB or ripple in iOS layouts; no hand-built iOS chrome (back-chevrons, large-title text, iOS-styled switches) on Android.
General Styling Rules
- Prefer flex gap over margin and padding styles
- Prefer padding over margin where possible
- Always account for safe area, either with stack headers, tabs, or ScrollView/FlatList
contentInsetAdjustmentBehavior="automatic" - Ensure both top and bottom safe area insets are accounted for
- Inline styles not StyleSheet.create unless reusing styles is faster
- For any motion or animation work, load the
expo-animationskill — it owns the animate-or-not decision, timing values, and interruption rules - Use
{ borderCurve: 'continuous' }for rounded corners unless creating a capsule shape - ALWAYS use a navigation stack title instead of a custom text element on the page
- When padding a ScrollView, use
contentContainerStylepadding and gap instead of padding on the ScrollView itself (reduces clipping) - CSS and Tailwind are not supported - use inline styles
Colors
Use the Color API from expo-router for native semantic colors. It is a type-safe wrapper over PlatformColor that exposes iOS UIKit colors through Color.ios.* and Android Material 3 colors through Color.android.material.* (static) or Color.android.dynamic.* (adapts to the user's wallpaper on Android 12+). These resolve on-device and automatically adapt to light/dark mode and accessibility settings, so you no longer maintain separate light/dark hex tables or a colors.web.ts file.
Color is platform-specific, so wrap each value in Platform.select with a default hex fallback for web. Centralize the palette in theme/colors.ts and import colors everywhere:
// theme/colors.ts
import { Platform } from "react-native";
import { Color } from "expo-router";
export const colors = {
label: Platform.select({
ios: Color.ios.label,
android: Color.android.dynamic.onSurface,
default: "#000000",
})!,
secondaryLabel: Platform.select({
ios: Color.ios.secondaryLabel,
android: Color.android.dynamic.onSurfaceVariant,
default: "#3c3c43",
})!,
separator: Platform.select({
ios: Color.ios.separator,
android: Color.android.dynamic.outlineVariant,
default: "#c6c6c8",
})!,
systemBackground: Platform.select({
ios: Color.ios.systemBackground,
android: Color.android.dynamic.surface,
default: "#ffffff",
})!,
secondarySystemBackground: Platform.select({
ios: Color.ios.secondarySystemBackground,
android: Color.android.dynamic.surfaceVariant,
default: "#f2f2f7",
})!,
systemBlue: Platform.select({
ios: Color.ios.systemBlue,
android: Color.android.dynamic.primary,
default: "#007aff",
})!,
};import { colors } from "@/theme/colors";
<View style={{ backgroundColor: colors.systemBackground }}>
<Text style={{ color: colors.label }}>Title</Text>
</View>;- iOS re-resolves these colors automatically when the system theme changes. On Android, call
useColorScheme()inside any component that renders them so it re-renders when the theme flips (required when React Compiler memoizes the component). - Don't pass
Color/PlatformColorvalues into Reanimated styles — they are opaque native color objects, not strings; use static colors there. Platform.select({...})!returnsstring | OpaqueColorValue. Most React Native style props acceptColorValue(string | OpaqueColorValue) so this works fine. But some third-party props only acceptstring(e.g.tintColoronexpo-image). Cast when needed:colors.label as string.
Text Styling
- Add the
selectableprop to every<Text/>element displaying important data or error messages - Counters should use
{ fontVariant: 'tabular-nums' }for alignment
Shadows
Use CSS boxShadow style prop. NEVER use legacy React Native shadow or elevation styles.
<View style={{ boxShadow: "0 1px 2px rgba(0, 0, 0, 0.05)" }} />'inset' shadows are supported.
Submitting Feedback
If you encounter errors, misleading or outdated information in this skill, report it so Expo can improve:
npx --yes submit-expo-feedback@latest --category skills --subject "expo-native-ui" "<actionable feedback>"Only submit when you have something specific and actionable to report. Include as much relevant context as possible. If an AI agent repeatedly failed or the user had to take over an Expo task, load the expo-skill-feedback skill and follow its eval-candidate flow instead of reusing the command above.
SKILL.md
SKILL.md holds the skill's instructions; it is edited on the Instructions tab.
agents/openai.yaml
interface:
display_name: "Expo Native UI"
short_description: "Build beautiful, native-feeling Expo screens: HIG styling, semantic colors, controls, SF Symbols, media, and visual effects"
default_prompt: "Use $expo-native-ui to style native-feeling Expo screens, choose semantic colors and native controls, add media and visual effects, and decide when Expo Go is enough before creating native builds."
references/controls.md
Native Controls
Native iOS controls provide built-in haptics, accessibility, and platform-appropriate styling.
Prefer
@expo/uifor these controls. ItsSlider,Switch,DateTimePicker,Menu, and segmented pickers render as real SwiftUI / Jetpack Compose and follow platform design conventions automatically — recommended over the React Native controls below. See theexpo-uiskill; use the React Native controls below as a fallback when you're not using@expo/ui.
Switch
Use for binary on/off settings. Has built-in haptics.
import { Switch } from "react-native";
import { useState } from "react";
const [enabled, setEnabled] = useState(false);
<Switch value={enabled} onValueChange={setEnabled} />;Don't recolor the Switch — native styling adapts to dark mode automatically.
Segmented Control
Use for non-navigational tabs or mode selection. Avoid changing default colors.
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+)
<SegmentedControl
values={[
{ label: "List", icon: "list.bullet" },
{ label: "Grid", icon: "square.grid.2x2" },
]}
selectedIndex={index}
onChange={({ nativeEvent }) => setIndex(nativeEvent.selectedSegmentIndex)}
/>Slider
Continuous value selection.
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}
/>;Discrete Steps
<Slider
value={value}
onValueChange={setValue}
minimumValue={0}
maximumValue={10}
step={1}
/>Date/Time Picker
Compact pickers with popovers. Has built-in haptics.
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 onlytime— Time onlydatetime— Date and time
Display Styles
// 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
<DateTimePicker
value={date}
mode="time"
minuteInterval={15}
/>Min/Max Dates
<DateTimePicker
value={date}
mode="date"
minimumDate={new Date(2020, 0, 1)}
maximumDate={new Date(2030, 11, 31)}
/>TextInput
Native text input with various keyboard types.
import { TextInput } from "react-native";
import { colors } from "@/theme/colors";
<TextInput
placeholder="Enter text..."
placeholderTextColor={colors.secondaryLabel as string}
style={{
padding: 12,
fontSize: 16,
borderRadius: 8,
backgroundColor: colors.secondarySystemBackground,
}}
/>Keyboard Types
// 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
<TextInput
multiline
numberOfLines={4}
textAlignVertical="top"
style={{ minHeight: 100 }}
/>Picker (Wheel)
For selection from many options (5+ items).
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
- No native Stepper: React Native has no
Steppercomponent — compose increment/decrement from two Buttons or a numeric TextInput - 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
// 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
// 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:
<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
<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
<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
<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, ortransparentkeyword - 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 on iOS for native feel. SF Symbols are an Apple asset and never render on Android - every icon needs a Material counterpart (see "Android: Material Icons" below). Never use FontAwesome or Ionicons on either platform, and never emoji as icons.
Basic Usage
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
<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- homegear- settingsmagnifyingglass- searchplus- addxmark- closechevron.left- backchevron.right- forwardarrow.left- back arrowarrow.right- forward arrow
Media
play.fill- playpause.fill- pausestop.fill- stopbackward.fill- rewindforward.fill- fast forwardspeaker.wave.2.fill- volumespeaker.slash.fill- mute
Camera
camera- cameracamera.fill- camera filledarrow.triangle.2.circlepath- flip cameraphoto- gallery/photosbolt- flashbolt.slash- flash off
Communication
message- messagemessage.fill- message filledenvelope- emailenvelope.fill- email filledphone- phonephone.fill- phone filledvideo- video callvideo.fill- video call filled
Social
heart- likeheart.fill- likedstar- favoritestar.fill- favoritedhand.thumbsup- thumbs uphand.thumbsdown- thumbs downperson- profileperson.fill- profile filledperson.2- peopleperson.2.fill- people filled
Content Actions
square.and.arrow.up- sharesquare.and.arrow.down- downloaddoc.on.doc- copytrash- deletepencil- editfolder- folderfolder.fill- folder filledbookmark- bookmarkbookmark.fill- bookmarked
Status & Feedback
checkmark- success/donecheckmark.circle.fill- completedxmark.circle.fill- error/failedexclamationmark.triangle- warninginfo.circle- infoquestionmark.circle- helpbell- notificationbell.fill- notification filled
Misc
ellipsis- more optionsellipsis.circle- more in circleline.3.horizontal- menu/hamburgerslider.horizontal.3- filtersarrow.clockwise- refreshlocation- locationlocation.fill- location filledmap- mapmappin- pinclock- timecalendar- calendarlink- linknosign- block/prohibited
Animated Symbols
<SymbolView
name="checkmark.circle"
animationSpec={{
effect: {
type: "bounce",
direction: "up",
},
}}
/>Animation Effects
bounce- Bouncy animationpulse- Pulsing effectvariableColor- Color cyclingscale- Scale animation
// 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
// 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
<SymbolView name="star" scale="small" />
<SymbolView name="star" scale="medium" /> // default
<SymbolView name="star" scale="large" />Multicolor Symbols
Some symbols support multiple colors:
<SymbolView
name="cloud.sun.rain.fill"
type="multicolor"
/>Android: Material Icons
On Android an SF Symbol source renders nothing - an app that ships SF-only iconography ships blank icons. Give every icon a Material source:
- Tab bars: pass
md(Material Symbol name) besidesfon NativeTabs triggers -<NativeTabs.Trigger.Icon sf="gear" md="settings" />(SDK 55+, see theexpo-routerskill). - In screens:
@expo/vector-iconsMaterial families, gated by platform:
import MaterialIcons from "@expo/vector-icons/MaterialIcons";
process.env.EXPO_OS === "ios" ? (
<SymbolView name="gear" tintColor={colors.label} style={{ width: 24, height: 24 }} />
) : (
<MaterialIcons name="settings" size={24} color={colors.label} />
);One icon family per platform - never a mixed set, never SF names on Android or Material glyphs on iOS.
Finding Symbol Names
- Use the SF Symbols app on macOS (free from Apple)
- Search at https://developer.apple.com/sf-symbols/
- Symbol names use dot notation:
square.and.arrow.up
Best Practices
- On iOS, always use SF Symbols over vector icon libraries; on Android, use Material icons (see above)
- Match symbol weight to nearby text weight
- Use
.fillvariants for selected/active states - Use the cross-platform
colorshelper (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
mirrorto emulate social apps - Use liquid glass buttons on cameras
- Icons:
arrow.triangle.2.circlepath(flip),photo(gallery),bolt(flash) - Gate the camera behind an explanation screen with a Grant button (as below) — never fire the permission prompt on mount
- Lazily request media library permission (at first save/pick)
The example uses the GlassButton helper defined in visual-effects.md (Glass Buttons section).
import React, { useRef, useState } from "react";
import { View, Pressable, Text } from "react-native";
import { CameraView, CameraType, useCameraPermissions } from "expo-camera";
import * as ImagePicker from "expo-image-picker";
import * as Haptics from "expo-haptics";
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 }}>
<Pressable onPress={requestPermission} style={{ padding: 12, borderRadius: 12 }}>
<Text style={{ color: "white" }}>Grant Permission</Text>
</Pressable>
</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 }}>
<Pressable 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:
import { useAudioPlayer } from 'expo-audio';
const player = useAudioPlayer({ uri: 'https://stream.nightride.fm/rektory.mp3' });
<Button title="Play" onPress={() => player.play()} />Audio Recording (Microphone)
import {
useAudioRecorder,
AudioModule,
RecordingPresets,
setAudioModeAsync,
useAudioRecorderState,
} from 'expo-audio';
import { Alert, Button } from 'react-native';
function App() {
const audioRecorder = useAudioRecorder(RecordingPresets.HIGH_QUALITY);
const recorderState = useAudioRecorderState(audioRecorder);
// Request the microphone on first record, not on mount — no prompt before user intent
const record = async () => {
const status = await AudioModule.requestRecordingPermissionsAsync();
if (!status.granted) {
Alert.alert('Permission to access microphone was denied');
return;
}
await setAudioModeAsync({ playsInSilentMode: true, allowsRecording: true });
await audioRecorder.prepareToRecordAsync();
audioRecorder.record();
};
const stop = () => audioRecorder.stop();
return (
<Button
title={recorderState.isRecording ? 'Stop' : 'Start'}
onPress={recorderState.isRecording ? stop : record}
/>
);
}Video Playback
Use expo-video not expo-av:
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: booleancontentFit: 'contain' | 'cover' | 'fill'nativeControls: booleanplaysInline: booleanstartsPictureInPictureAutomatically: boolean
Saving Media
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:
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
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:
// 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
// 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:
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:
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.
import { BlurView } from "expo-blur";
<BlurView tint="systemMaterial" intensity={100} />;Tint Options
// 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):
<BlurView tint="systemMaterial" intensity={50} /> // Subtle
<BlurView tint="systemMaterial" intensity={100} /> // FullRounded Corners
BlurView requires overflow: 'hidden' to clip rounded corners:
<BlurView
tint="systemMaterial"
intensity={100}
style={{
borderRadius: 16,
overflow: 'hidden',
}}
/>Overlay Pattern
Common pattern for overlaying blur on content:
<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+.
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:
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:
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
<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
Check both guards: isLiquidGlassAvailable() (OS support) and isGlassEffectAPIAvailable() — some iOS 26 beta versions lack the API and crash without the second check.
import { isLiquidGlassAvailable, isGlassEffectAPIAvailable } from "expo-glass-effect";
const canUseGlass = isLiquidGlassAvailable() && isGlassEffectAPIAvailable();Fallback Pattern
import { GlassView, isLiquidGlassAvailable, isGlassEffectAPIAvailable } from "expo-glass-effect";
import { BlurView } from "expo-blur";
function AdaptiveGlass({ children, style }) {
if (isLiquidGlassAvailable() && isGlassEffectAPIAvailable()) {
return <GlassView style={style}>{children}</GlassView>;
}
return (
<BlurView tint="systemMaterial" intensity={80} style={style}>
{children}
</BlurView>
);
}When the user has Reduce Transparency enabled (AccessibilityInfo.isReduceTransparencyEnabled()), skip glass and blur — render a solid colors.systemBackground surface instead.
Sheet with Glass Background
Make sheet backgrounds liquid glass on iOS 26+:
<Stack.Screen
name="sheet"
options={{
presentation: "formSheet",
sheetGrabberVisible: true,
sheetAllowedDetents: [0.5, 1.0],
contentStyle: { backgroundColor: "transparent" },
}}
/>Best Practices
- Use
systemMaterialtints for automatic dark mode support - Always set
overflow: 'hidden'on BlurView for rounded corners — but never on aGlassViewor its ancestors: glass clips itself viaborderRadius(+borderCurve), and outside clipping cuts off the rim highlight and press bulge - Use
isInteractiveon GlassView only for actual controls (buttons, pressables) — leave background surfaces non-interactive - Check
isLiquidGlassAvailable()andisGlassEffectAPIAvailable(), provide fallbacks, and respect Reduce Transparency with a solid surface - Never animate opacity on a
GlassViewor an ancestor — animate content around a stable glass surface, or switchglassEffectStyleon the mounted view to change its look - 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)
{
"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
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-depsNote: --legacy-peer-deps may be required due to peer dependency conflicts with canary Expo versions.
Metro Configuration
Create metro.config.js in project root:
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
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
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
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:
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
// 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
// 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
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
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:
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:
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:
// @ts-expect-error - WebGPU renderer types don't match
await state.gl.init();3. Blank Screen
Problem: Canvas renders but nothing visible
Solution:
- Ensure camera is positioned correctly and looking at scene
- Add lighting (objects are black without light)
- Check that
extend()includes all components used
4. Performance Issues
Problem: Low frame rate or stuttering
Solution:
- Reduce polygon count in geometries
- Use
useMemofor static data - Limit particle count
- Use
instancedMeshfor many identical objects
5. Peer Dependency Errors
Problem: npm install fails with ERESOLVE
Solution: Use --legacy-peer-deps:
npm install <packages> --legacy-peer-depsBuilding
WebGPU requires a custom build:
npx expo prebuild
npx expo run:iosNote: 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 controlsDecision 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
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>
);
}Frontmatter written into each target's SKILL.md.
Common
No fields set for this target.