Install any skill in seconds. Free to start, no credit card required.
Get Started Free →This skill should be used when users need to work with Expo UI (@expo/ui) for integrating SwiftUI components into React Native applications. It provides comprehensive guidance on installation, Host component, SwiftUI primitives, layouts, modifiers, native tabs navigation, and v10 preview features.
| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-02 | ✗→✓ | ▲ Improved | 182% | 0% |
| case-03 | ✗→✓ | ▲ Improved | 355% | 0% |
| case-07 | ✗→✓ | ▲ Improved | 593% | 0% |
| case-08 | ✗→✓ | ▲ Improved | 197% | 0% |
| case-10 | ✗→✓ | ▲ Improved | 296% | 0% |
Expo UI brings native SwiftUI components to React Native, providing a 1-to-1 mapping to SwiftUI views for building modern iOS, macOS, and tvOS interfaces. Use Expo UI to leverage SwiftUI's declarative syntax and native performance while maintaining React Native's flexibility.
> Available in SDK 54 and above.
Use this skill when:
Supported Platforms:
Requirements:
glassEffect modifier> Roadmap: Android (Jetpack Compose) and Web (DOM) support planned for future releases.
> ⚠️ Version Conflict: Official documentation is based on v9 (stable, ~0.2.0-beta.7). Local examples use v10 which may contain breaking changes. Always check TypeScript types for current API signatures. > > When using v10 preview features (marked with ⚠️), be aware that APIs may differ from v9 stable documentation.
Install the package in your Expo project:
bashnpx expo install @expo/ui
For existing React Native apps, ensure expo is installed first.
references/expo-ui-guide.mdreferences/expo-ui-api-reference.mdreferences/expo-ui-guide.md, references/expo-ui-api-reference.mdreferences/expo-ui-api-reference.md, references/expo-ui-v10-examples.mdreferences/expo-ui-api-reference.mdreferences/expo-ui-guide.md, references/expo-ui-v10-examples.mdreferences/expo-ui-v10-examples.mdreferences/expo-ui-guide.mdThe <Host> component bridges React Native (UIKit) and SwiftUI. Use UIHostingController to render SwiftUI views within React Native.
Key characteristics:
<svg> in DOM or <Canvas> in react-native-skiastyle prop for flexbox layoutsmatchContents prop to size to childrenBasic usage:
jsximport { CircularProgress, Host } from '@expo/ui/swift-ui'; <Host matchContents> <CircularProgress /> </Host>
With flexbox layout:
jsx<Host style={{ flex: 1, margin: 32 }}> <VStack spacing={32}> <Text>Hello, world!</Text> <Button onPress={handlePress}>Click</Button> </VStack> </Host>
SwiftUI uses a different layout system than React Native's flexbox. Use these components for layouts:
HStack and VStack:
spacing prop for consistent gapsjsximport { HStack, VStack, Spacer } from '@expo/ui/swift-ui'; <VStack spacing={16}> <HStack spacing={8}> <Text>Label</Text> <Spacer /> <Switch value={enabled} onValueChange={setEnabled} /> </HStack> </VStack>
Form and Section:
<Section> to create grouped contentjsx<Form> <Section> <Text>Settings</Text> <Switch label="Airplane Mode" value={mode} onValueChange={setMode} /> </Section> </Form>
Important: Flexbox styles only work on <Host> component, not inside SwiftUI context. Use HStack/VStack for layouts once inside SwiftUI.
SwiftUI modifiers customize appearance and behavior. Import from @expo/ui/swift-ui/modifiers and apply via modifiers prop.
jsximport { Text, Host } from '@expo/ui/swift-ui'; import { padding, frame, background } from '@expo/ui/swift-ui/modifiers'; <Text size={32} modifiers={[ padding({ all: 16 }), frame({ width: 200, height: 100 }), background('#007aff') ]}> Styled Text </Text>
Common modifiers:
padding() - Add insets around contentframe() - Set explicit dimensionsbackground() - Set background color or contentclipShape() - Clip to shapes (roundedRectangle, circle, etc.)Platform: iOS, tvOS (borderless variant not available on Apple TV)
jsximport { Button, Host } from '@expo/ui/swift-ui'; <Host style={{ flex: 1 }}> <Button variant="default" onPress={handlePress}> Edit profile </Button> </Host>
Props:
onPress?: () => void - Callback when button is pressedsystemImage?: SFSymbol - SF Symbol name (only used if children is a string)role?: 'default' | 'cancel' | 'destructive' - Button role (iOS only)controlSize?: 'mini' | 'small' | 'regular' | 'large' | 'extraLarge' - Control sizevariant?: ButtonVariant - Button style variantchildren?: string | React.ReactNode - Button contentcolor?: string - Button colordisabled?: boolean - Disabled stateVariants: default, bordered, borderless, borderedProminent, plain, glass, glassProminent, accessoryBar, accessoryBarAction, card, link
Platform: iOS, macOS, tvOS
jsximport { Text, Host } from '@expo/ui/swift-ui'; <Host> <Text size={24} weight="bold" color="primary"> Hello, world! </Text> </Host>
Props:
children: string - Text content (must be a string, not React.ReactNode)size?: number - Font sizeweight?: 'ultraLight' | 'thin' | 'light' | 'regular' | 'medium' | 'semibold' | 'bold' | 'heavy' | 'black' - Font weightdesign?: 'default' | 'rounded' | 'serif' | 'monospaced' - Font designlineLimit?: number - Maximum number of linescolor?: string - Text colorImportant: Text children must be a string. Use individual size and weight props instead of a font object.
jsximport { Image, Host } from '@expo/ui/swift-ui'; <Host> <Image systemName="airplane" color="white" size={18} modifiers={[ frame({ width: 28, height: 28 }), background('#ffa500'), clipShape('roundedRectangle') ]} /> </Host>
jsximport { CircularProgress, LinearProgress, Host } from '@expo/ui/swift-ui'; <Host style={{ width: 300 }}> <CircularProgress progress={0.5} color="blue" /> <LinearProgress progress={0.7} color="orange" /> </Host>
Platform: iOS, tvOS
jsximport { Switch, Host } from '@expo/ui/swift-ui'; <Host matchContents> <Switch checked={checked} onValueChange={setChecked} label="Play music" variant="switch" color="#ff0000" /> </Host>
Variants: switch (toggle), checkbox
Platform: iOS, tvOS
jsximport { TextField, Host } from '@expo/ui/swift-ui'; <Host matchContents> <TextField autocorrection={false} defaultValue="Enter text" onChangeText={setValue} /> </Host>
Platform: iOS (wheel not available on Apple TV)
jsximport { Picker, Host } from '@expo/ui/swift-ui'; <Host matchContents> <Picker options={['$', '$$', '$$$', '$$$$']} selectedIndex={selectedIndex} onOptionSelected={({ nativeEvent: { index } }) => setSelectedIndex(index)} variant="segmented" /> </Host>
Variants: segmented, wheel, menu
Platform: iOS (not available on Apple TV)
jsximport { Slider, Host } from '@expo/ui/swift-ui'; <Host style={{ minHeight: 60 }}> <Slider value={value} onValueChange={setValue} /> </Host>
Platform: iOS (not available on Apple TV)
jsximport { ColorPicker, Host } from '@expo/ui/swift-ui'; <Host style={{ width: 400, height: 200 }}> <ColorPicker label="Select a color" selection={color} onValueChanged={setColor} /> </Host>
Platform: iOS (not available on Apple TV)
jsximport { DateTimePicker, Host } from '@expo/ui/swift-ui'; <Host matchContents> <DateTimePicker onDateSelected={setDate} displayedComponents="date" initialDate={date.toISOString()} variant="wheel" /> </Host>
Components: date (default) or hourAndMinute for time
Platform: iOS
jsximport { BottomSheet, Host, Text } from '@expo/ui/swift-ui'; <Host style={{ position: 'absolute', width }}> <BottomSheet isOpened={isOpened} onIsOpenedChange={setIsOpened}> <Text>Hello, world!</Text> </BottomSheet> </Host>
Platform: iOS, tvOS
> Also known as DropdownMenu
jsximport { ContextMenu, Button, Picker, Host } from '@expo/ui/swift-ui'; <Host style={{ width: 150, height: 50 }}> <ContextMenu> <ContextMenu.Items> <Button systemImage="person.crop.circle" onPress={handleAction}> Hello </Button> <Picker label="Options" options={options} variant="menu" /> </ContextMenu.Items> <ContextMenu.Trigger> <Button variant="bordered">Show Menu</Button> </ContextMenu.Trigger> </ContextMenu> </Host>
Platform: iOS (not available on Apple TV)
jsximport { Gauge, Host, PlatformColor } from '@expo/ui/swift-ui'; <Host matchContents> <Gauge min={{ value: 0, label: '0' }} max={{ value: 1, label: '1' }} current={{ value: 0.5 }} color={[ PlatformColor('systemRed'), PlatformColor('systemYellow'), PlatformColor('systemGreen') ]} type="circularCapacity" /> </Host>
Platform: iOS, tvOS
jsximport { List, LabelPrimitive, Host } from '@expo/ui/swift-ui'; <Host style={{ flex: 1 }}> <List editModeEnabled={editMode} onSelectionChange={(items) => alert(`Selected: ${items.join(', ')}`)} moveEnabled={canMove} onMoveItem={(from, to) => alert(`Moved ${from} to ${to}`)} onDeleteItem={(index) => alert(`Deleted ${index}`)} listStyle="automatic" > {data.map((item, index) => ( <LabelPrimitive key={index} title={item.text} systemImage={item.systemImage} /> ))} </List> </Host>
Features: Edit mode, move, delete, selection
Import from @expo/ui/swift-ui/modifiers:
jsximport { padding, frame, background, clipShape } from '@expo/ui/swift-ui/modifiers';
Layout modifiers:
padding({ all, horizontal, vertical, top, bottom, leading, trailing }) - Add spacingframe({ width, height, minWidth, maxWidth, minHeight, maxHeight }) - Set dimensionsfixedSize() - Prevent automatic sizingStyling modifiers:
background(color or content) - Set backgroundforegroundStyle(color) - Set foreground colorclipShape(shape) - Clip to shape (roundedRectangle, circle, capsule)cornerRadius(radius) - Round cornersfont({ size, weight, design }) - Font styling (Note: Text component uses individual props: size, weight, design)Behavior modifiers:
disabled(bool) - Enable/disable interactionanimation(Animation.spring(), condition) - Animate changes> ⚠️ v10 Preview: glassEffect requires Xcode 26+ and iOS 26+. May differ from v9 APIs.
jsximport { glassEffect, glassEffectId, animation, Animation } from '@expo/ui/swift-ui/modifiers'; <Text size={16} weight="medium" modifiers={[ padding({ all: 16 }), glassEffect({ glass: { variant: 'clear' } }) ]}> Glass effect text </Text>
Advanced glass effects with namespace:
jsximport { Namespace } from '@expo/ui/swift-ui'; const namespaceId = useId(); <Namespace id={namespaceId}> <Image systemName="paintbrush.fill" modifiers={[ glassEffect({ glass: { variant: 'clear' } }), glassEffectId('uniqueId', namespaceId), animation(Animation.spring({ duration: 0.8 }), isExpanded) ]} /> </Namespace>
> ⚠️ v10 Preview: These modifiers may have different APIs in v9 stable. Check TypeScript types.
Button style modifiers:
buttonStyle('glass' | 'glassProminent' | 'bordered' | 'borderless' | 'borderedProminent' | 'plain')controlSize('mini' | 'small' | 'regular' | 'large' | 'extraLarge')labelStyle('iconOnly' | 'titleAndIcon' | 'titleOnly')jsx<Button modifiers={[ buttonStyle('glass'), controlSize('large') ]}> Glass Button </Button>
Form control modifiers:
pickerStyle('menu' | 'segmented' | 'wheel')tag(value) - Tag picker options for selectionfont({ size }) - Font customizationjsx<Picker modifiers={[pickerStyle('menu')]} selection={selectedIndex} onSelectionChange={setSelectedIndex}> {options.map((option, index) => ( <Text key={index} modifiers={[tag(index)]}> {option} </Text> ))} </Picker>
> ⚠️ v10 Preview: The following components and features are from v10 and may contain breaking changes from v9 stable APIs. Always check TypeScript types for current signatures.
Platform: iOS/macOS
Container component for glass morphism effects with coordinated animations.
jsximport { GlassEffectContainer, Namespace } from '@expo/ui/swift-ui'; const namespaceId = useId(); <Host style={{ flex: 1 }}> <Namespace id={namespaceId}> <GlassEffectContainer spacing={30} modifiers={[ padding({ all: 30 }), cornerRadius(20) ]}> <VStack spacing={25}> <Text>Glass content</Text> </VStack> </GlassEffectContainer> </Namespace> </Host>
Wrapper for labeled form elements with multi-part label support.
jsximport { LabeledContent } from '@expo/ui/swift-ui'; <LabeledContent label="Name"> <Text>John Doe</Text> </LabeledContent> <LabeledContent label={ <> <Text>Title</Text> <Text>Subtitle</Text> </> }> <Text>Value</Text> </LabeledContent>
Expandable/collapsible content sections.
jsximport { DisclosureGroup } from '@expo/ui/swift-ui'; <DisclosureGroup isExpanded={isExpanded} onStateChange={setIsExpanded} label="Show Details"> <Text>Detailed content here</Text> </DisclosureGroup>
Empty state or placeholder view with icon, title, and description.
jsximport { ContentUnavailableView } from '@expo/ui/swift-ui'; <ContentUnavailableView title="No items" systemImage="tray" description="Add items to get started." />
jsximport { buttonStyle, controlSize, fixedSize } from '@expo/ui/swift-ui/modifiers'; <Button label="Glass" modifiers={[ buttonStyle('glass'), controlSize('large'), fixedSize() ]} /> <Button label="Glass Prominent" modifiers={[ buttonStyle('glassProminent'), controlSize('extraLarge') ]} />
ColorPicker with opacity:
jsx<ColorPicker label="Select color" selection={color} supportsOpacity onValueChanged={setColor} />
Picker with menu style and tags:
jsximport { pickerStyle, tag } from '@expo/ui/swift-ui/modifiers'; <Picker label="Menu Picker" modifiers={[pickerStyle('menu')]} selection={selectedIndex} onSelectionChange={setSelectedIndex}> {options.map((option, index) => ( <Text key={index} modifiers={[tag(index)]}> {option} </Text> ))} </Picker>
React Native components can be nested inside Expo UI components. Expo UI automatically creates a UIViewRepresentable wrapper.
jsximport { Text, Host, VStack } from '@expo/ui/swift-ui'; import { View } from 'react-native'; <Host> <VStack spacing={8}> <Text>SwiftUI Text</Text> <View style={{ width: 100, height: 100, backgroundColor: 'blue' }} /> </VStack> </Host>
Important limitations:
center, bounds, frame, transform) - don't set these directly on React Native views<Host> wrapper to return to Expo UI componentsiOS-only components:
Apple TV exclusions:
Common patterns:
Platform.OS checks for platform-specific codeNative tabs provide platform-native tab navigation using system tab bars. Unlike JavaScript tabs, native tabs follow platform conventions and offer native performance.
> Native tabs is experimental (SDK 54+, API subject to change). For fully custom designs, consider JavaScript tabs instead.
Choose native tabs for:
Choose JavaScript/custom tabs for:
Ensure expo-router is installed and configured:
bashnpx expo install expo-router
app.json configuration:
json{ "expo": { "plugins": ["expo-router"] } }
Create a tab layout using file-based routing:
File structure:
app/
├── _layout.tsx
├── index.tsx
└── settings.tsxapp/_layout.tsx:
tsximport { NativeTabs, Icon, Label } from 'expo-router/unstable-native-tabs'; export default function TabLayout() { return ( <NativeTabs> <NativeTabs.Trigger name="index"> <Label>Home</Label> <Icon sf="house.fill" drawable="ic_home" /> </NativeTabs.Trigger> <NativeTabs.Trigger name="settings"> <Icon sf="gear" drawable="ic_settings" /> <Label>Settings</Label> </NativeTabs.Trigger> </NativeTabs> ); }
> Tabs are not automatically added. Explicitly define each tab with NativeTabs.Trigger.
Icons:
SF Symbols (iOS):
tsx<Icon sf={{ default: 'house', selected: 'house.fill' }} />
Android drawables:
tsx<Icon drawable="ic_home" />
Custom images:
tsx<Icon src={require('./assets/home.png')} /> <Icon src={{ default: require('./assets/home.png'), selected: require('./assets/home-active.png') }} />
Labels:
tsx<Label>Home</Label> // Show label <Label hidden /> // Hide label
Badges:
tsx<Badge>9+</Badge> // Text badge <Badge /> // Dot badge (no text)
Global styling:
tsx<NativeTabs backgroundColor="white" iconColor="blue" tintColor="purple" blurEffect="systemMaterial" labelStyle={{ fontSize: 12, fontWeight: '600' }} labelVisibilityMode="auto" minimizeBehavior="automatic" >
Per-tab styling:
tsx<NativeTabs.Trigger name="page"> <NativeTabs.Trigger.TabBar backgroundColor="white" iconColor="red" /> <Label>Page</Label> </NativeTabs.Trigger>
Key props:
backgroundColor: Tab bar background coloriconColor: Default icon colortintColor: Selected icon colorblurEffect: iOS blur effect ('systemMaterial', 'extraLight', 'dark', etc.)labelStyle: Typography settingslabelVisibilityMode: Android label visibility ('auto' | 'selected' | 'labeled' | 'unlabeled')minimizeBehavior: iOS 26+ minimize behavior ('automatic' | 'never' | 'onScrollDown' | 'onScrollUp')backBehavior: Android back button behavior ('history' | 'none' | 'initialRoute')Hide tabs conditionally:
tsxconst shouldHideMessagesTab = true; <NativeTabs.Trigger name="messages" hidden={shouldHideMessagesTab} />
> Hidden tabs cannot be navigated to in any way.
Disable pop to top (iOS):
tsx<NativeTabs.Trigger name="index" disablePopToTop> <Label>Home</Label> </NativeTabs.Trigger>
Disable scroll to top (iOS):
tsx<NativeTabs.Trigger name="index" disableScrollToTop> <Label>Home</Label> </NativeTabs.Trigger>
iOS 26+ search tab:
tsx<NativeTabs.Trigger name="search" role="search"> <Label>Search</Label> </NativeTabs.Trigger>
iOS 26+ tab bar minimize:
tsx<NativeTabs minimizeBehavior="onScrollDown"> <NativeTabs.Trigger name="index"> <Label>Home</Label> </NativeTabs.Trigger> </NativeTabs>
Vector icons integration:
tsximport MaterialIcons from '@expo/vector-icons/MaterialIcons'; import { VectorIcon } from 'expo-router/unstable-native-tabs'; import { Platform } from 'react-native'; <NativeTabs.Trigger name="index"> <Label>Home</Label> {Platform.select({ ios: <Icon sf="house.fill" />, android: <Icon src={<VectorIcon family={MaterialIcons} name="home" />} />, })} </NativeTabs.Trigger>
Android:
iOS:
tvOS:
Known limitations:
disableTransparentOnScrollEdge propKey differences:
| JavaScript Tabs | Native Tabs | |-----------------|-------------| | Tabs.Screen | NativeTabs.Trigger | | Options object | Child components (<Icon>, <Label>) | | Automatic tabs | Explicit trigger definition | | Mock headers | Use nested <Stack /> |
Before (JavaScript tabs):
tsx<Tabs.Screen name="home" options={{ tabBarIcon: ({ focused, color, size }) => ( <Icon name="home" color={color} size={size} /> ), }} />
After (Native tabs):
tsx<NativeTabs.Trigger name="home"> <Icon sf="house.fill" /> <Label>Home</Label> </NativeTabs.Trigger>
Use Stacks inside tabs:
Native tabs don't have mock headers. Nest Stack layouts:
tsx// app/home/_layout.tsx import { Stack } from 'expo-router'; export default function HomeLayout() { return ( <Stack> <Stack.Screen name="index" options={{ title: 'Home' }} /> <Stack.Screen name="details" options={{ title: 'Details' }} /> </Stack> ); }
Conditional tabs based on auth:
tsxconst { user } = useAuth(); return ( <NativeTabs> <NativeTabs.Trigger name="index"> <Label>Home</Label> </NativeTabs.Trigger> {user && ( <NativeTabs.Trigger name="profile"> <Label>Profile</Label> </NativeTabs.Trigger> )} </NativeTabs> );
Badge updates:
tsxconst [unreadCount, setUnreadCount] = useState(0); <NativeTabs.Trigger name="messages"> {unreadCount > 0 && ( <Badge>{unreadCount > 99 ? '99+' : unreadCount}</Badge> )} <Label>Messages</Label> </NativeTabs.Trigger>
jsximport { Form, Section, HStack, VStack, Button, Switch, Text, Image, Spacer, Host } from '@expo/ui/swift-ui'; import { background, clipShape, frame } from '@expo/ui/swift-ui/modifiers'; function SettingsView() { const [airplaneMode, setAirplaneMode] = useState(true); return ( <Host style={{ flex: 1 }}> <Form> <Section> <HStack spacing={8}> <Image systemName="airplane" color="white" size={18} modifiers={[ frame({ width: 28, height: 28 }), background('#ffa500'), clipShape('roundedRectangle') ]} /> <Text>Airplane Mode</Text> <Spacer /> <Switch value={airplaneMode} onValueChange={setAirplaneMode} /> </HStack> </Section> </Form> </Host> ); }
jsximport { CircularProgress, LinearProgress, VStack, Host } from '@expo/ui/swift-ui'; function ProgressView() { return ( <Host style={{ flex: 1, margin: 32 }}> <VStack spacing={32}> <CircularProgress progress={0.5} color="blue" /> <LinearProgress progress={0.7} color="orange" /> </VStack> </Host> ); }
jsximport { Form, Section, TextField, Switch, Picker, Button, Host } from '@expo/ui/swift-ui'; function FormView() { const [name, setName] = useState(''); const [enabled, setEnabled] = useState(true); const [category, setCategory] = useState(0); return ( <Host style={{ flex: 1 }}> <Form> <Section header="User Information"> <TextField label="Name" defaultValue={name} onChangeText={setName} /> <Switch label="Enabled" value={enabled} onValueChange={setEnabled} /> <Picker label="Category" options={['General', 'Advanced', 'Expert']} selectedIndex={category} onOptionSelected={({ nativeEvent: { index } }) => setCategory(index)} variant="segmented" /> </Section> </Form> </Host> ); }
For detailed API specifications, code examples, and implementation guides, refer to:
references/expo-ui-guide.mdreferences/expo-ui-api-reference.mdreferences/expo-ui-v10-examples.mdreferences/native-tabs-guide.mdreferences/native-tabs-api.md<Host> and keep SwiftUI layouts self-containedmodifiers prop rather than inline stylesOther measured skills in the registry, with their headline benchmark lift.