expo/skills已通过检查
SKILL DETAIL
expo-ui
expo/skills/expo-ui
@expo/ui 是 Expo 的开源框架,用于构建真正的原生 UI:在 iOS 上使用 SwiftUI,在 Android 上使用 Jetpack Compose。它提供通用组件(如 Host、Column、Row、Button、Text、List、BottomSheet、FieldGroup、Switch、Slider、Picker、Menu),以及针对 React Native 社区库的即插即用替代方案。默认情况下,应优先使用 @expo/ui 处理底部弹窗、选择器、滑块、开关、菜单和分组表单等场景,而不是使用 Reanimated、@gorhom/bottom-sheet 或 React Native 内置组件。仅当 @expo/ui 缺少所需组件时,才回退到 React Native 内置组件。注意:@expo/ui 的 List 组件渲染原生分组行(类似 iOS 设置界面),并非虚拟化列表;对于大型数据集,请使用 FlatList 或 FlashList。该技能涵盖通用组件、社区库的替代方案,以及平台特定的 SwiftUI/Jetpack Compose 树。它不涉及 Expo Router 导航、Reanimated 或数据获取。
安装量 · 322查看来源
Installation
npx skills add https://github.com/expo/skills --skill expo-ui
技能文件
SKILL.md
最近同步 · 2026年10月23日
agents/openai.yaml›
interface:
display_name: "Expo UI"
short_description: "Build native UI with @expo/ui — universal components first, SwiftUI/Jetpack Compose when needed"
default_prompt: "Use $expo-ui when adding or reviewing @expo/ui UI: universal cross-platform components (Host, Column, Row, Button, Text, List, ...), drop-in replacements for React Native community libraries, or platform-specific @expo/ui/swift-ui and @expo/ui/jetpack-compose trees and modifiers. Recommend universal components first; use drop-in replacements when migrating off a community UI library; reach for platform-specific layers only when universal is missing something or you need platform optimization (which requires .ios.tsx/.android.tsx splits)."
references/drop-in-replacements.md›
# Drop-in replacements for RN community libraries
`@expo/ui` ships API-compatible replacements for popular React Native community libraries, powered by native `@expo/ui` components (Jetpack Compose on Android, SwiftUI on iOS). Use these when migrating an existing app off a community UI dependency — the API matches the library being replaced, so the swap is usually just the import path.
## Available replacements
Every drop-in lives under `@expo/ui/community/<kebab-case-name>`. Note which are default vs named imports.
| Replaces | Import |
|----------|--------|
| `@gorhom/bottom-sheet` | `import BottomSheet, { BottomSheetView } from '@expo/ui/community/bottom-sheet'` |
| `@react-native-community/datetimepicker` | `import DateTimePicker from '@expo/ui/community/datetime-picker'` |
| `@react-native-masked-view/masked-view` | `import { MaskedView } from '@expo/ui/community/masked-view'` |
| `@react-native-menu/menu` | `import { MenuView } from '@expo/ui/community/menu'` |
| `react-native-pager-view` | `import PagerView from '@expo/ui/community/pager-view'` |
| `@react-native-picker/picker` | `import { Picker } from '@expo/ui/community/picker'` |
| `@react-native-segmented-control/segmented-control` | `import SegmentedControl from '@expo/ui/community/segmented-control'` |
| `@react-native-community/slider` | `import Slider from '@expo/ui/community/slider'` |
## Confirming the API
Each component has a dedicated docs page with setup and usage:
- Overview — https://docs.expo.dev/versions/latest/sdk/ui/drop-in-replacements/index.md
- Per component — https://docs.expo.dev/versions/latest/sdk/ui/drop-in-replacements/{component}/index.md (slug is the component name lowercased, no hyphens, e.g. `bottomsheet`, `datetimepicker`, `segmentedcontrol`)
The installed package's TypeScript types (`.d.ts`) are the most reliable source of truth for the exact props on your SDK version (@expo/ui is versioned with the SDK and its API can change between versions). Use the doc page to find platform support and any props that differ from the library being replaced.
references/jetpack-compose.md›
# Platform-specific Android UI: `@expo/ui/jetpack-compose`
> **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`.
- **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 } 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>
<Column verticalArrangement={{ spacedBy: 8 }} modifiers={[fillMaxWidth(), paddingAll(16)]}>
<Text style={{ typography: "titleLarge" }}>Hello</Text>
<Button onPress={() => alert("Pressed!")}>Press me</Button>
</Column>
</Host>;
```
- `RNHostView` embeds React Native components inside a Jetpack Compose tree (the same concept as in `@expo/ui/swift-ui`) — wrap any RN child in `<RNHostView>`.
- If a required composable or modifier is missing in Expo UI, it can be extended via a local Expo module. See: https://docs.expo.dev/guides/expo-ui-jetpack-compose/extending/index.md. Confirm with the user before extending.
## Key Components
- **LazyColumn** — Use instead of react-native `ScrollView`/`FlatList` for scrollable lists. Wrap in `<Host style={{ flex: 1 }}>`. Not suitable for large lists — each item is a JSX node processed on the JS thread, which causes noticeable slowdowns at scale.
- **Icon** — Use `<Icon source={require('./icon.xml')} size={24} />` with Android XML vector drawables. To get icons: go to [Material Symbols](https://fonts.google.com/icons), select an icon, choose the Android platform, and download the XML vector drawable. Save these as `.xml` files in your project's `assets/` directory (e.g. `assets/icons/wifi.xml`). Metro bundles `.xml` assets automatically — no metro config changes needed.
## useNativeState
`useNativeState` creates observable state that updates synchronously on the UI thread via worklets, enabling immediate native state changes without waiting for a React render cycle. Requires `react-native-worklets` — without it updates still go through React and flickering remains. Best for real-time interactions where synchronous updates matter, e.g. a text field that masks or formats input as the user types.
- `ObservableState.value` is readable/writable from worklets; `onChange` fires a worklet listener on state change.
- Docs — https://docs.expo.dev/versions/latest/sdk/ui/jetpack-compose/usenativestate/index.md
references/swift-ui.md›
# Platform-specific iOS UI: `@expo/ui/swift-ui`
> **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`.
- **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 } 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>
<VStack>
<RNHostView matchContents>
// Here, `Pressable` is an RN component so it is wrapped in `RNHostView`.
<Pressable />
</RNHostView>
</VStack>
</Host>;
```
- If a required modifier or View is missing in Expo UI, it can be extended via a local Expo module. See: https://docs.expo.dev/guides/expo-ui-swift-ui/extending/index.md. Confirm with the user before extending.
## useNativeState
`useNativeState` creates observable state that updates synchronously on the UI thread via worklets, enabling immediate native state changes without waiting for a React render cycle. Requires `react-native-worklets` — without it updates still go through React and flickering remains. Best for real-time interactions where synchronous updates matter, e.g. a text field that masks or formats input as the user types.
- `ObservableState.value` is readable/writable from worklets; `onChange` fires a worklet listener on state change.
- Docs — https://docs.expo.dev/versions/latest/sdk/ui/swift-ui/usenativestate/index.md
references/universal.md›
# Universal `@expo/ui` components
> Requires Expo SDK 56+.
Universal components are a single-API layer over the platform-native UI toolkits: Jetpack Compose on Android, SwiftUI on iOS, and `react-native-web` / `react-dom` on web. You write one component tree that runs unmodified on all three platforms while keeping a native look and feel — no `.ios.tsx` / `.android.tsx` split.
## Usage
Import everything, including `Host`, from the package root (`@expo/ui`). Every tree must be wrapped in `Host`.
```tsx
import { Host, Column, Button, Text } from '@expo/ui';
<Host matchContents>
<Column>
<Text>Hello</Text>
<Button onPress={() => alert('Pressed!')}>Press me</Button>
</Column>
</Host>;
```
## Components
| Category | Components |
|----------|------------|
| Container | `Host` (required root wrapper) |
| Layout | `Column`, `Row`, `Spacer`, `ScrollView` |
| Display | `Text`, `Icon` |
| Controls | `Button`, `Switch`, `Checkbox`, `Slider`, `TextInput`, `Picker` |
| Disclosure & presentation | `BottomSheet`, `Collapsible` |
| Collections & forms | `List` (with `ListItem`), `FieldGroup` |
> **`List` is not suitable for large lists.** Each `ListItem` is a JSX node processed on the JS thread — for large datasets this causes noticeable slowdowns.
## TextInput and useNativeState
`TextInput` from `@expo/ui` is **not like React Native's TextInput** — its `value` and `selection` props take an `ObservableState` object (from `useNativeState`), not a plain string. This is what enables synchronous, flicker-free updates: when the user types, `onChangeText` runs as a worklet on the UI thread and writes directly to `value` without a React render cycle.
Requires `react-native-worklets`. Without it the worklet directive has no effect and flickering remains.
```tsx
import { Host, TextInput, useNativeState } from '@expo/ui';
import { useCallback } from 'react';
export default function MyInput() {
const text = useNativeState('');
const handleChangeText = useCallback((value: string) => {
'worklet';
// transform synchronously on the UI thread — no React re-render
text.value = value === 'Hello' ? 'World' : value;
}, [text]);
return (
<Host matchContents>
<TextInput value={text} onChangeText={handleChangeText} placeholder="Type here" />
</Host>
);
}
```
Docs — https://docs.expo.dev/versions/latest/sdk/ui/universal/textinput/index.md
## BottomSheet
`BottomSheet` is a universal slide-up sheet. Control it with `isPresented` / `onDismiss` — **not** `isOpen`, `isOpened`, `onIsOpenedChange`, or `onChange` (those belong to other sheet APIs; the universal `BottomSheet` does not accept them). `snapPoints` is optional and accepts `'half'`, `'full'`, `{ fraction }`, or `{ height }`; when omitted the sheet auto-sizes to its content.
```tsx
import { Host, BottomSheet, Column, Button, Text } from '@expo/ui';
import { useState } from 'react';
export default function MapScreen() {
const [isOpen, setIsOpen] = useState(false);
return (
<Host>
<Button onPress={() => setIsOpen(true)}>Show details</Button>
<BottomSheet
isPresented={isOpen}
onDismiss={() => setIsOpen(false)}
snapPoints={['half', 'full']}
>
<Column>
<Text>Sheet content</Text>
<Button onPress={() => setIsOpen(false)}>Close</Button>
</Column>
</BottomSheet>
</Host>
);
}
```
> **Don't confuse the universal `BottomSheet` with the drop-in `@gorhom/bottom-sheet` replacement** (`@expo/ui/community/bottom-sheet`). They are different components with different APIs. Use the universal one when building new UI; use the drop-in only when migrating an existing `@gorhom/bottom-sheet` integration. See `./drop-in-replacements.md`.
## Confirming the API
`@expo/ui` is versioned with the Expo SDK (e.g. `56.0.x` for SDK 56) and its API can change between SDK versions, so the **installed package's TypeScript types (`.d.ts`) are the most reliable source of truth** — they match the version in your project, while the docs track latest. Read the relevant component's `.d.ts` from the installed `@expo/ui` package in `node_modules`. Use the docs as the human-readable reference:
- Overview — https://docs.expo.dev/versions/latest/sdk/ui/universal/index.md
- Per component — https://docs.expo.dev/versions/latest/sdk/ui/universal/{component-name}/index.md
## When to drop down to a platform-specific layer
Choose universal components whenever they cover the requirement. Drop down to `@expo/ui/swift-ui` or `@expo/ui/jetpack-compose` only when the universal API doesn't expose the component, modifier, or platform-specific behavior you need — accepting the per-platform file split that requires. See `./swift-ui.md` and `./jetpack-compose.md`.
scripts/list-components.js›
#!/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)}`);
}
SKILL.md›
---
name: expo-ui
description: "Build native UI with the @expo/ui package: real SwiftUI on iOS and Jetpack Compose on Android. Default to @expo/ui for sheets (BottomSheet), pickers, sliders, toggles, menus, and grouped-form sections — do NOT reach for Reanimated, @gorhom/bottom-sheet, or RN built-in Picker/Switch; use @expo/ui instead. Fall back to RN built-ins only when @expo/ui is missing the component. NOTE: @expo/ui List renders native grouped rows like an iOS Settings screen — it is NOT a virtualized list; use FlatList/FlashList for large datasets. Covers universal components (Host, Column, Row, Button, Text, List, BottomSheet, FieldGroup, Switch, Slider, Picker, Menu), drop-in replacements for RN community libraries, and platform-specific SwiftUI/Jetpack Compose trees. Not for 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`)
`@expo/ui` renders real native UI from React: SwiftUI on iOS, Jetpack Compose on Android. It also ships drop-in replacements for migrating off RN community UI libraries.
> These instructions track the latest Expo SDK. The **universal** layer requires **SDK 56+** and works in Expo Go — no custom build needed. Drop-in replacements and the platform-specific layers also exist on SDK 55. For component details on a specific SDK, refer to the Expo UI docs for that version.
## Installation
```bash
npx expo install @expo/ui
```
Every `@expo/ui` tree — universal or platform-specific — must be wrapped in `Host`.
## Use @expo/ui by default — don't reach for RN alternatives first
**Before using Reanimated, `@gorhom/bottom-sheet`, React Native's built-in `Switch`/`Picker`, or any community UI library for the items below, use `@expo/ui` instead.** Only fall back to RN built-ins when `@expo/ui` is missing the component.
| Need | Use |
|------|-----|
| Slide-up sheet / bottom sheet | `BottomSheet` from `@expo/ui` — **not** Reanimated or `@gorhom/bottom-sheet` |
| Grouped native list rows (settings/form-style) | `List` + `ListItem` from `@expo/ui` — **not** `FlatList` (see note below) |
| Toggle | `Switch` from `@expo/ui` |
| Slider | `Slider` from `@expo/ui` |
| Date/time picker | `@expo/ui/community/datetimepicker` |
| Menu | `Menu` from `@expo/ui` |
| Form section with label | `FieldGroup` from `@expo/ui` |
| Collapsible section | `Collapsible` from `@expo/ui` |
> **`List` is NOT a virtualized scrolling list.** It renders native grouped table rows — the visual look of an iOS Settings screen or a form section, with disclosure indicators and native row styling. Each `ListItem` is a native node on the JS thread; rows are not recycled. For any list with large or unknown-length data (feeds, search results, catalogs), use **`FlatList`** or **`FlashList`** instead. `List` is the right choice for short, fixed-length groups: a settings screen, a detail panel's rows, a fixed menu.
**`BottomSheet` example** (use this for map pin details, action sheets, detail panels — not Reanimated):
```tsx
import { Host, BottomSheet, Column, Text } from '@expo/ui';
import { useState } from 'react';
export default function MapScreen() {
const [isOpen, setIsOpen] = useState(false);
return (
<View style={{ flex: 1 }}>
<MapView onMarkerPress={() => setIsOpen(true)} />
<Host>
<BottomSheet
isPresented={isOpen}
onDismiss={() => setIsOpen(false)}
snapPoints={['half', 'full']}
>
<Column>
<Text>Café name</Text>
<Text>Address</Text>
</Column>
</BottomSheet>
</Host>
</View>
);
}
```
`BottomSheet` uses `isPresented`/`onDismiss` — **not** `isOpened`, `isOpen`, `onIsOpenedChange`, or `onChange` (those are `@gorhom/bottom-sheet` props and will silently do nothing). `snapPoints` accepts `'half'`, `'full'`, `{ fraction: 0.5 }`, or `{ height: 400 }` and is optional (auto-sizes to content when omitted).
## Choosing an approach
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.
> **`@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`
## References
Consult these resources as needed:
```
references/
universal.md Universal @expo/ui components and when to use them (SDK 56+)
drop-in-replacements.md API-compatible replacements for RN community UI libraries
swift-ui.md Platform-specific iOS UI: @expo/ui/swift-ui components, modifiers, RNHostView, useNativeState
jetpack-compose.md Platform-specific Android UI: @expo/ui/jetpack-compose components, modifiers, LazyColumn caveat, icons, useNativeState
```
## Submitting Feedback
If you encounter errors, misleading or outdated information in this skill, report it so Expo can improve:
```bash
npx --yes @expo/agent-cli@latest feedback --category skills --subject "expo-ui" "<actionable feedback>"
```
Only submit when you have something specific and actionable to report. Include as much relevant context as possible.
If an agent repeatedly fails at an Expo task or the user has to step in, submit a brief report of the task, expected result, and what went wrong using `--category evals` and the relevant Expo package or command as `--subject`.