Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions plugins/expo/skills/expo-ui/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
---
name: expo-ui
description: "Build native UI with the @expo/ui package: real SwiftUI on iOS and Jetpack Compose on Android rendered from React in an Expo or React Native app. Covers universal cross-platform components (Host, Column, Row, Button, Text, List, and more imported from @expo/ui), drop-in replacements for popular React Native community libraries (BottomSheet, DateTimePicker, Slider, Menu, etc.), and platform-specific SwiftUI (@expo/ui/swift-ui) and Jetpack Compose (@expo/ui/jetpack-compose) trees and modifiers. Use when adding or reviewing @expo/ui Host/RNHostView trees, building native-feeling UI where standard React Native components fall short (lists with swipe actions and sections, settings forms with toggles, menus, sheets, pickers, sliders), choosing between universal and platform-specific components, or replacing an RN community UI library with a native @expo/ui equivalent. Not for custom native modules, Expo Router navigation, Reanimated, or data fetching."
description: "Build native UI with the @expo/ui package: real SwiftUI on iOS and Jetpack Compose on Android rendered from React in an Expo or React Native app. Covers universal cross-platform components (Host, Column, Row, Button, Text, List, and more imported from @expo/ui), drop-in replacements for popular React Native community libraries (BottomSheet, DateTimePicker, Slider, Menu, etc.), and platform-specific SwiftUI (@expo/ui/swift-ui, iOS only) and Jetpack Compose (@expo/ui/jetpack-compose, Android only) trees and modifiers. Use when adding or reviewing @expo/ui Host/RNHostView trees, building native-feeling UI where standard React Native components fall short (grouped settings forms with toggles, sections, menus, sheets, pickers, sliders), choosing between universal and platform-specific components, or replacing an RN community UI library with a native @expo/ui equivalent. Not for custom native modules, Expo Router navigation, Reanimated, or data fetching."
version: 1.0.0
license: MIT
allowed-tools: "Bash(node *expo-ui/scripts/list-components.js *)"
---

# Expo UI (`@expo/ui`)
Expand All @@ -27,7 +28,9 @@ Work down this list and stop at the first layer that meets the need:

1. **Universal components — start here.** Import from the `@expo/ui` root. One component tree runs unmodified on iOS, Android, and web from a single source (Compose on Android, SwiftUI on iOS, `react-native-web`/`react-dom` on web). No platform file splits. → `./references/universal.md`

2. **Platform-specific (SwiftUI / Jetpack Compose).** Import from `@expo/ui/swift-ui` or `@expo/ui/jetpack-compose`. Use **only** when the universal layer is missing a component or modifier you need, or when you need platform-specific behavior or optimization. **Downside:** you write two trees and split them into `.ios.tsx` / `.android.tsx` files (or branch on `Platform.OS`) — more code to maintain. → `./references/swift-ui.md` and `./references/jetpack-compose.md`
2. **Platform-specific (SwiftUI / Jetpack Compose).** Import from `@expo/ui/swift-ui` or `@expo/ui/jetpack-compose`. Use **only** when the universal layer is missing a component or modifier you need, or when you need platform-specific behavior or optimization. **Downside:** you write two trees and split them into `.ios.tsx` / `.android.tsx` files (or branch on `Platform.OS`) — more code to maintain.

> **`@expo/ui/swift-ui` is iOS-only. `@expo/ui/jetpack-compose` is Android-only.** Importing either in a file that runs on the other platform will crash at runtime with "Unable to get view config" errors. Isolate platform-specific trees in `.ios.tsx` / `.android.tsx` files placed in `components/` (never inside `app/` — Expo Router does not support platform extensions for route files), or guard with `Platform.OS` in a regular route file. `Host` must always be imported from `@expo/ui` (the universal package root), not from the platform-specific sub-packages. → `./references/swift-ui.md` and `./references/jetpack-compose.md`

**Already using an RN community UI library?** `@expo/ui` also ships **drop-in replacements** — API-compatible swaps for popular libraries (`@gorhom/bottom-sheet`, `@react-native-community/datetimepicker`, and more), imported from `@expo/ui/community/<name>`. This is a migration side-path for replacing an existing dependency, not a step in the universal-vs-platform decision above. → `./references/drop-in-replacements.md`

Expand Down
40 changes: 37 additions & 3 deletions plugins/expo/skills/expo-ui/references/jetpack-compose.md
Original file line number Diff line number Diff line change
@@ -1,18 +1,52 @@
# Platform-specific Android UI: `@expo/ui/jetpack-compose`

Use this layer only when the universal `@expo/ui` components don't cover what you need on Android (see `./universal.md` first). This requires a platform-specific tree, typically in an `.android.tsx` file.
> **Android only.** Code that imports from `@expo/ui/jetpack-compose` will crash on iOS with "Unable to get view config" errors. Always place this code in an `.android.tsx` component file or guard it with `Platform.OS === 'android'`. `Host` must be imported from `@expo/ui` (the universal root), not from `@expo/ui/jetpack-compose`.

Use this layer only when the universal `@expo/ui` components don't cover what you need on Android (see `./universal.md` first). This requires a platform-specific tree.

### File placement with Expo Router

**Do not put `.android.tsx` files inside `app/` or `src/app/`.** Expo Router does not support platform-extension suffixes for route files and will throw a "no fallback sibling" Render Error.

Place platform-specific component files in `components/` (or any directory outside the route tree), then import them from a regular route file:

```
src/components/ProductList.android.tsx ← Compose tree lives here
src/app/product-list.tsx ← regular Expo Router route, imports the component
```

`src/app/product-list.tsx`:
```tsx
import ProductList from '../components/ProductList';
export default ProductList;
```

Alternatively, keep everything in one regular route file and branch on `Platform.OS`:

```tsx
// src/app/product-list.tsx
import { Platform } from 'react-native';
const ComposeList = Platform.OS === 'android' ? require('../components/ProductList.android').default : null;
```

## Instructions

- Expo UI's API mirrors Jetpack Compose's API. Use Jetpack Compose and Material Design 3 knowledge to decide which components or modifiers to use. If you need deeper Jetpack Compose or Material 3 guidance (e.g. which component to pick, layout patterns, theming), spawn a subagent to research [Jetpack Compose](https://developer.android.com/develop/ui/compose/components) and [Material Design 3](https://m3.material.io/) best practices.
- Components are imported from `@expo/ui/jetpack-compose`, modifiers from `@expo/ui/jetpack-compose/modifiers`.
- **Always read the `.d.ts` type files** to confirm the exact API before using a component or modifier — read the relevant `{ComponentName}/index.d.ts` from the installed `@expo/ui/jetpack-compose` package in `node_modules`. This is the most reliable source of truth.
- **Before writing any code, run the list-components script** to get the exact components and modifiers available in the installed version:
```bash
node <skill-root>/scripts/list-components.js <project-path> # names only (compact)
node <skill-root>/scripts/list-components.js <project-path> --docs # with one-line descriptions
```
(`<skill-root>` is the directory containing this `references/` folder.)
- **Always read the `.d.ts` type files** to confirm prop shapes and signatures — read the relevant `{ComponentName}/index.d.ts` from the installed `@expo/ui/jetpack-compose` package in `node_modules`. This is the most reliable source of truth.
- When about to use a component, fetch its docs to confirm the API — https://docs.expo.dev/versions/latest/sdk/ui/jetpack-compose/{component-name}/index.md
- When unsure about a modifier's API, refer to the docs — https://docs.expo.dev/versions/latest/sdk/ui/jetpack-compose/modifiers/index.md
- Every Jetpack Compose tree must be wrapped in `Host`. Use `<Host matchContents>` for intrinsic sizing, or `<Host style={{ flex: 1 }}>` when you need explicit size (e.g. as a parent of `LazyColumn`). Example:

```jsx
import { Host, Column, Button, Text } from "@expo/ui/jetpack-compose";
import { Host } from "@expo/ui"; // Host always from universal root
import { Column, Button, Text } from "@expo/ui/jetpack-compose";
import { fillMaxWidth, paddingAll } from "@expo/ui/jetpack-compose/modifiers";

<Host matchContents>
Expand Down
43 changes: 40 additions & 3 deletions plugins/expo/skills/expo-ui/references/swift-ui.md
Original file line number Diff line number Diff line change
@@ -1,19 +1,56 @@
# Platform-specific iOS UI: `@expo/ui/swift-ui`

Use this layer only when the universal `@expo/ui` components don't cover what you need on iOS (see `./universal.md` first). This requires a platform-specific tree, typically in an `.ios.tsx` file.
> **iOS only.** Code that imports from `@expo/ui/swift-ui` will crash on Android with "Unable to get view config" errors. Always place this code in an `.ios.tsx` component file or guard it with `Platform.OS === 'ios'`. `Host` must be imported from `@expo/ui` (the universal root), not from `@expo/ui/swift-ui`.

Use this layer only when the universal `@expo/ui` components don't cover what you need on iOS (see `./universal.md` first). This requires a platform-specific tree.

### File placement with Expo Router

**Do not put `.ios.tsx` files inside `app/` or `src/app/`.** Expo Router does not support platform-extension suffixes for route files and will throw a "no fallback sibling" Render Error.

Place platform-specific component files in `components/` (or any directory outside the route tree), then import them from a regular route file:

```
src/components/ProfileEditor.ios.tsx ← SwiftUI tree lives here
src/app/profile-editor.tsx ← regular Expo Router route, imports the component
```

`src/app/profile-editor.tsx`:
```tsx
import ProfileEditor from '../components/ProfileEditor';
export default ProfileEditor;
```

Alternatively, keep everything in one regular route file and branch on `Platform.OS`:

```tsx
// src/app/profile-editor.tsx
import { Platform } from 'react-native';
// import SwiftUI components only when on iOS to avoid Android crash
const SwiftUIForm = Platform.OS === 'ios' ? require('../components/ProfileEditor.ios').default : null;
```

Or more simply, put the `Platform.OS` guard and the SwiftUI import in the same route file (safe because Metro only bundles `.ios.tsx` imports on iOS builds when using platform extensions in `components/`).

## Instructions

- Expo UI's API mirrors SwiftUI's API. Use SwiftUI knowledge to decide which components or modifiers to use.
- Components are imported from `@expo/ui/swift-ui`, modifiers from `@expo/ui/swift-ui/modifiers`.
- **The installed package's TypeScript types (`.d.ts`) are the most reliable source of truth** for the exact API on your SDK version (@expo/ui is versioned with the SDK and its API can change between versions) — read the relevant `{Component}/index.d.ts` from the installed `@expo/ui/swift-ui` package in `node_modules`. Use the docs below as the human-readable reference.
- **Before writing any code, run the list-components script** to get the exact components and modifiers available in the installed version:
```bash
node <skill-root>/scripts/list-components.js <project-path> # names only (compact)
node <skill-root>/scripts/list-components.js <project-path> --docs # with one-line descriptions
```
(`<skill-root>` is the directory containing this `references/` folder.)
- **The installed package's TypeScript types (`.d.ts`) are the most reliable source of truth** for prop shapes and signatures — read the relevant `{Component}/index.d.ts` from the installed `@expo/ui/swift-ui` package in `node_modules`. Use the docs below as the human-readable reference.
- When about to use a component, fetch its docs to confirm the API — https://docs.expo.dev/versions/latest/sdk/ui/swift-ui/{component-name}/index.md
- When unsure about a modifier's API, refer to the docs — https://docs.expo.dev/versions/latest/sdk/ui/swift-ui/modifiers/index.md
- Every SwiftUI tree must be wrapped in `Host`.
- `RNHostView` is specifically for embedding RN components inside a SwiftUI tree. Example:

```jsx
import { Host, VStack, RNHostView } from "@expo/ui/swift-ui";
import { Host } from "@expo/ui"; // Host always from universal root
import { VStack, RNHostView } from "@expo/ui/swift-ui"; // platform components from swift-ui
import { Pressable } from "react-native";

<Host matchContents>
Expand Down
193 changes: 193 additions & 0 deletions plugins/expo/skills/expo-ui/scripts/list-components.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
#!/usr/bin/env node
/**
* List available @expo/ui components and modifiers installed in a project.
*
* Usage:
* node list-components.js <project-path> [--docs]
*
* --docs Include a one-line JSDoc description per modifier.
* Omit (default) for a compact names-only list that consumes fewer tokens.
*
* Output goes to stdout. Redirect or capture it to inject into an agent prompt.
*/

'use strict';

const fs = require('fs');
const path = require('path');

const projectPath = process.argv[2];
const withDocs = process.argv.includes('--docs');

if (!projectPath) {
console.error('Usage: node list-components.js <project-path> [--docs]');
process.exit(1);
}

const pkgRoot = path.join(projectPath, 'node_modules', '@expo', 'ui');
if (!fs.existsSync(pkgRoot)) {
console.error(`@expo/ui not found in ${projectPath}/node_modules`);
process.exit(1);
}

// Read installed version from package.json
let version = 'unknown';
try {
const pkg = JSON.parse(fs.readFileSync(path.join(pkgRoot, 'package.json'), 'utf8'));
version = pkg.version || 'unknown';
} catch (_) {}

// ---------------------------------------------------------------------------
// Component extraction — parse `export * from './Name'` in an index.d.ts
// ---------------------------------------------------------------------------
// TypeScript type names that are not components — skip anything matching these suffixes
const TYPE_SUFFIX = /(?:Props|Ref|Handle|Params|Config|Options|Type|Types|Value|Values|Colors|Style|Styles|Event|Events|Alignment|Animation|Spec)$/;

function extractComponents(indexFile) {
if (!fs.existsSync(indexFile)) return [];
const src = fs.readFileSync(indexFile, 'utf8');
const names = [];
for (const line of src.split('\n')) {
// export * from './ComponentName' or export * from './ComponentName/index'
const m = line.match(/^export \* from ['"]\.\/([^/'"]+)/);
if (m) {
const name = m[1];
// Skip non-component re-exports (types, utils, state internals)
if (/^(types|utils|index|State|hooks|colors|layout-types|MaterialSymbols)/.test(name)) continue;
if (TYPE_SUFFIX.test(name)) continue;
names.push(name);
}
// export { Name, ... } from './Something' — pick up named re-exports too
const n = line.match(/^export \{([^}]+)\}/);
if (n) {
for (const part of n[1].split(',')) {
// Skip `type Foo` re-exports
if (/^\s*type\s/.test(part)) continue;
const id = part.trim().split(/\s+as\s+/)[0].trim();
if (id && /^[A-Z]/.test(id) && !TYPE_SUFFIX.test(id)) names.push(id);
}
}
}
return [...new Set(names)].sort();
}

// ---------------------------------------------------------------------------
// Modifier extraction — parse `export declare const/function <name>` in
// a flat modifiers/index.d.ts, optionally with the preceding JSDoc summary.
// ---------------------------------------------------------------------------
function extractModifiers(modifiersFile) {
if (!fs.existsSync(modifiersFile)) return [];
const src = fs.readFileSync(modifiersFile, 'utf8');
const lines = src.split('\n');
const results = [];
const seen = new Set();

for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const m = line.match(/^export declare (?:const|function) ([a-zA-Z_][a-zA-Z0-9_]*)/);
if (!m) continue;
const name = m[1];
// Skip type-only helpers and internal symbols
if (/^(is|filter|create|type|export)/.test(name) && name !== 'frame') continue;
if (seen.has(name)) continue;
seen.add(name);

if (!withDocs) {
results.push({ name });
continue;
}

// Find /** that opens the JSDoc block immediately preceding this export,
// then scan forward through the block for the first plain-prose summary.
let jsdocStart = -1;
for (let j = i - 1; j >= 0; j--) {
const jl = lines[j].trim();
if (jl === '/**') { jsdocStart = j; break; }
if (jl !== '' && jl !== '*/' && !jl.startsWith('*')) break;
}

let summary = '';
let deprecated = false;
if (jsdocStart >= 0) {
let inCodeBlock = false;
for (let k = jsdocStart + 1; k < i; k++) {
const kl = lines[k].trim();
if (kl.startsWith('* ```')) { inCodeBlock = !inCodeBlock; continue; }
if (inCodeBlock) continue;
// Check @deprecated anywhere in the block (may appear after @param)
if (kl.startsWith('* @deprecated')) { deprecated = true; continue; }
// Extract summary from opening prose only — stop at first non-deprecated tag
if (!summary) {
if (kl.startsWith('* @')) continue; // skip tags while looking for prose
if (kl === '*/' || kl === '/**' || kl === '*') continue;
if (kl.startsWith('* ')) {
const text = kl.slice(2).trim();
if (!text.startsWith('-') && !text.startsWith('<')) {
summary = text.replace(/\.$/, '');
}
}
}
}
}
results.push({ name, summary, deprecated });
}

return results;
}

// ---------------------------------------------------------------------------
// Format helpers
// ---------------------------------------------------------------------------
function formatNames(names) {
return names.join(', ');
}

function formatModifiers(mods) {
if (!withDocs) return mods.map(m => m.name).join(', ');
const lines = [];
for (const m of mods) {
const dep = m.deprecated ? ' [deprecated]' : '';
const desc = m.summary ? ` — ${m.summary}` : '';
lines.push(` ${m.name}${dep}${desc}`);
}
return lines.join('\n');
}

// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
const buildRoot = path.join(pkgRoot, 'build');

// Universal
const universalComponents = extractComponents(path.join(buildRoot, 'universal', 'index.d.ts'));

// Swift-UI (iOS only)
const swiftuiComponents = extractComponents(path.join(buildRoot, 'swift-ui', 'index.d.ts'));
const swiftuiModifiers = extractModifiers(path.join(buildRoot, 'swift-ui', 'modifiers', 'index.d.ts'));

// Jetpack Compose (Android only)
const composeComponents = extractComponents(path.join(buildRoot, 'jetpack-compose', 'index.d.ts'));
const composeModifiers = extractModifiers(path.join(buildRoot, 'jetpack-compose', 'modifiers', 'index.d.ts'));

const docsNote = withDocs ? ' (with descriptions)' : ' (names only — run with --docs for descriptions)';
console.log(`@expo/ui ${version}${docsNote}\n`);

console.log(`@expo/ui — universal (iOS + Android + web)`);
console.log(` Components: ${formatNames(universalComponents)}\n`);

console.log(`@expo/ui/swift-ui — iOS ONLY (crashes on Android)`);
console.log(` Components: ${formatNames(swiftuiComponents)}`);
if (withDocs) {
console.log(` Modifiers:\n${formatModifiers(swiftuiModifiers)}`);
} else {
console.log(` Modifiers: ${formatModifiers(swiftuiModifiers)}`);
}
console.log();

console.log(`@expo/ui/jetpack-compose — Android ONLY (crashes on iOS)`);
console.log(` Components: ${formatNames(composeComponents)}`);
if (withDocs) {
console.log(` Modifiers:\n${formatModifiers(composeModifiers)}`);
} else {
console.log(` Modifiers: ${formatModifiers(composeModifiers)}`);
}
Loading