AthenodeAthenode

Back to Mobile (Expo & React Native)

expo-native-ui

Created here

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.

SKILL.md

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-ui first. @expo/ui provides 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 the expo-ui skill 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.js

Running 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:

  1. Start with Expo Go: Run npx expo start and scan the QR code with Expo Go
  2. Check if features work: Test your app thoroughly in Expo Go
  3. 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 — see references/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 (see expo-ui skill) 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's List + ListItem. For large or unknown-length scrolling lists (feeds, search results, catalogs), use FlatList or FlashList — @expo/ui's List is not virtualized.
  • Never use modules removed from React Native such as Picker, WebView, SafeAreaView, or AsyncStorage
  • Never use legacy expo-permissions
  • expo-audio not expo-av
  • expo-video not expo-av
  • expo-symbols (SymbolView) for SF Symbols on iOS, not @expo/vector-icons — see references/icons.md. SF Symbols are Apple-only: on Android every icon needs a Material source (md prop on NativeTabs triggers; in-screen options under "Android: Material Icons" in icons.md), never SF-only iconography
  • react-native-safe-area-context not react-native SafeAreaView
  • process.env.EXPO_OS not Platform.OS
  • React.use not React.useContext
  • expo-image Image component instead of intrinsic element img
  • expo-glass-effect for liquid glass backdrops
  • Color from expo-router for native semantic colors, not raw PlatformColor (type-safe, auto-adapts to light/dark)
  • In SDK 56+, never import from @react-navigation/* directly — use expo-router/react-navigation instead (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 useWindowDimensions over Dimensions.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-fetching skill
  • 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-animation skill's keyboard recipe (react-native-keyboard-controller) - never Keyboard.addListener plus 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-animation skill — 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 contentContainerStyle padding 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 / PlatformColor values into Reanimated styles — they are opaque native color objects, not strings; use static colors there.
  • Platform.select({...})! returns string | OpaqueColorValue. Most React Native style props accept ColorValue (string | OpaqueColorValue) so this works fine. But some third-party props only accept string (e.g. tintColor on expo-image). Cast when needed: colors.label as string.

Text Styling

  • Add the selectable prop 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/ui for these controls. Its Slider, 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 the expo-ui skill; 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 only
  • time — Time only
  • datetime — 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 Stepper component — 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, 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 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 - 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

<SymbolView
  name="checkmark.circle"
  animationSpec={{
    effect: {
      type: "bounce",
      direction: "up",
    },
  }}
/>
Animation Effects
  • bounce - Bouncy animation
  • pulse - Pulsing effect
  • variableColor - Color cycling
  • scale - 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) beside sf on NativeTabs triggers - <NativeTabs.Trigger.Icon sf="gear" md="settings" /> (SDK 55+, see the expo-router skill).
  • In screens: @expo/vector-icons Material 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

  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

  • 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 .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)
  • 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: boolean
  • contentFit: 'contain' | 'cover' | 'fill'
  • nativeControls: boolean
  • playsInline: boolean
  • startsPictureInPictureAutomatically: 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} /> // Full
Rounded 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 systemMaterial tints for automatic dark mode support
  • Always set overflow: 'hidden' on BlurView for rounded corners — but never on a GlassView or its ancestors: glass clips itself via borderRadius (+ borderCurve), and outside clipping cuts off the rim highlight and press bulge
  • Use isInteractive on GlassView only for actual controls (buttons, pressables) — leave background surfaces non-interactive
  • Check isLiquidGlassAvailable() and isGlassEffectAPIAvailable(), provide fallbacks, and respect Reduce Transparency with a solid surface
  • Never animate opacity on a GlassView or an ancestor — animate content around a stable glass surface, or switch glassEffectStyle on 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-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:

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>
  );
}

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:

  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:

npm install <packages> --legacy-peer-deps

Building

WebGPU requires a custom build:

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

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.

Ready to ship better, together?

Spec it. Decompose it. Ship it. All with your AI agent.

Start for free

Join engineers building with Athenode today.