@expo/ui brings native SwiftUI (iOS/tvOS/macOS) and Jetpack Compose (Android) components to React Native via Expo. It is NOT a JS UI library — it exposes real native platform primitives directly to JavaScript.
- Package:
@expo/ui - Install:
npx expo install @expo/ui - Requires: Development build (not Expo Go)
- Available since: SDK 54 (SwiftUI focus), SDK 55 (expanded Jetpack Compose)
Two separate import paths:
@expo/ui/swift-ui— iOS/tvOS/macOS components@expo/ui/swift-ui/modifiers— SwiftUI modifiers@expo/ui/jetpack-compose— Android components@expo/ui/jetpack-compose/modifiers— Jetpack Compose modifiers
Host is required as the root container to bridge from React Native (UIKit/Android Views) into SwiftUI/Jetpack Compose. Think of it like <svg> in HTML or <Canvas> in react-native-skia.
import { Host, Text } from '@expo/ui/swift-ui';
// Host wraps SwiftUI content — style it with RN styles
<Host style={{ flex: 1 }}>
<Text>Hello SwiftUI</Text>
</Host>
// matchContents makes Host shrink to fit its SwiftUI content
<Host matchContents>
<Text>Sized to content</Text>
</Host>Key Host props:
matchContents?: boolean | { vertical?: boolean, horizontal?: boolean }— Shrink to fit contentstyle?: StyleProp<ViewStyle>— Standard RN styles (flexbox works here)colorScheme?: 'light' | 'dark'layoutDirection?: 'leftToRight' | 'rightToLeft'ignoreSafeArea?: 'all' | 'keyboard'(SwiftUI) /ignoreSafeAreaKeyboardInsets?: boolean(Compose)
Important: Inside Host, Yoga/flexbox is NOT available. Use HStack, VStack, Row, Column etc. for layout. RN styles only work on the Host itself.
Import from @expo/ui/swift-ui.
| Component | Key Props | Description |
|---|---|---|
Host |
matchContents, style, colorScheme |
Root bridge container |
HStack |
spacing?: number, alignment?: 'top'|'center'|'bottom'|'firstTextBaseline'|'lastTextBaseline' |
Horizontal stack |
VStack |
spacing?: number, alignment?: 'leading'|'center'|'trailing' |
Vertical stack |
ZStack |
alignment?: 16 options |
Z-axis layering |
Group |
— | Grouping container |
Spacer |
— | Flexible space |
ScrollView |
axes?: 'vertical'|'horizontal'|'both', showsIndicators?: boolean |
Scrollable container |
Grid |
verticalSpacing, horizontalSpacing, alignment |
Grid layout. Use Grid.Row for rows |
List |
selection, onSelectionChange |
Native list with selection. Use List.ForEach for items |
Section |
title, header, footer, isExpanded, onIsExpandedChange |
Section within List/Form |
Form |
— | Form container (iOS Settings style) |
Namespace |
id: string (use React.useId()) |
For matched geometry animations |
| Component | Key Props | Description |
|---|---|---|
Text |
children, markdownEnabled?: boolean |
Text with markdown support |
Label |
title, systemImage?: SFSymbol, icon?: ReactNode |
Label with optional SF Symbol |
LabeledContent |
label?: string|ReactNode, children |
Content with label (key-value display) |
| Component | Key Props | Description |
|---|---|---|
Button |
onPress, label, systemImage?: SFSymbol, role?: 'default'|'cancel'|'destructive', target?: string, children |
Native button |
Toggle |
isOn, label, systemImage, onIsOnChange |
Switch/toggle |
Slider |
value, step, min, max, onValueChange, label, minimumValueLabel, maximumValueLabel |
Value slider |
Stepper |
label, defaultValue, step, min, max, onValueChanged |
Increment/decrement |
TextField |
defaultValue, placeholder, onChangeText, onSubmit, multiline, numberOfLines, keyboardType, autoFocus |
Text input |
SecureField |
defaultValue, placeholder, onChangeText, onSubmit, autoFocus |
Password input |
Picker |
selection, onSelectionChange, label, systemImage, children |
Selection picker (use tag modifier on options) |
DatePicker |
title, selection?: Date, range?: {start, end}, displayedComponents?: ('date'|'hourAndMinute')[], onDateChange |
Date/time picker |
ColorPicker |
selection, label, onSelectionChange, supportsOpacity |
Color picker |
| Component | Key Props | Description |
|---|---|---|
Image |
systemName: SFSymbol, size, color, variableValue?: 0.0-1.0, onPress |
SF Symbol image |
ProgressView |
value?: number|null, timerInterval?: {lower, upper}, countsDown |
Progress indicator (null = indeterminate) |
Gauge |
value, min, max, currentValueLabel, minimumValueLabel, maximumValueLabel |
Gauge display |
Chart |
data: ChartDataPoint[], type?: 'line'|'point'|'bar'|'area'|'pie'|'rectangle', showGrid, animate, showLegend |
Data visualization |
Divider |
— | Visual separator |
ContentUnavailableView |
title, systemImage, description |
Empty state view |
| Component | Key Props | Description |
|---|---|---|
BottomSheet |
isPresented, onIsPresentedChange, fitToContents |
Bottom sheet modal |
Popover |
isPresented, onIsPresentedChange, attachmentAnchor, arrowEdge |
Popover with Popover.Trigger and Popover.Content |
ConfirmationDialog |
title, isPresented, onIsPresentedChange, titleVisibility |
With .Trigger, .Actions, .Message |
| Component | Key Props | Description |
|---|---|---|
Menu |
label, systemImage, onPrimaryAction, children |
Dropdown menu |
ContextMenu |
children |
With .Trigger, .Preview, .Items |
ControlGroup |
label, systemImage, children |
Group controls in menus |
| Component | Key Props | Description |
|---|---|---|
ShareLink |
item, getItemAsync, subject, message, preview?: {title, image} |
Native share sheet |
DisclosureGroup |
label, isExpanded, onIsExpandedChange |
Expandable/collapsible |
GlassEffectContainer |
spacing |
Container for glass effects (iOS 26+) |
Rectangle, RoundedRectangle (cornerRadius), Circle, Ellipse, Capsule (cornerStyle), UnevenRoundedRectangle (individual corner radii), ConcentricRectangle
Import from @expo/ui/jetpack-compose.
| Component | Key Props | Description |
|---|---|---|
Host |
matchContents, style, colorScheme, layoutDirection |
Root bridge container |
Row |
horizontalArrangement, verticalAlignment |
Horizontal layout |
Column |
verticalArrangement, horizontalAlignment |
Vertical layout |
Box |
contentAlignment |
Generic container |
FlowRow |
horizontalArrangement, verticalArrangement |
Wrapping horizontal layout |
LazyColumn |
verticalArrangement, horizontalAlignment, contentPadding |
Virtualized vertical list |
Spacer |
— | Flexible space |
AnimatedVisibility |
visible: boolean |
Animated show/hide |
RNHostView |
matchContents, verticalScrollEnabled |
Host RN views inside Compose |
Arrangement values: 'start'|'end'|'center'|'spaceBetween'|'spaceAround'|'spaceEvenly'|{spacedBy: number}
| Component | Key Props | Description |
|---|---|---|
Text |
color, overflow, softWrap, maxLines, minLines, style: TextStyle |
Text display |
TextStyle: { typography?, fontSize?, fontWeight?, fontStyle?, textAlign?, textDecoration?, letterSpacing?, lineHeight? }
Typography values: 'displayLarge'|'displayMedium'|'displaySmall'|'headlineLarge'|'headlineMedium'|'headlineSmall'|'titleLarge'|'titleMedium'|'titleSmall'|'bodyLarge'|'bodyMedium'|'bodySmall'|'labelLarge'|'labelMedium'|'labelSmall'
| Component | Key Props | Description |
|---|---|---|
Button |
onPress, variant?: 'default'|'bordered'|'borderless'|'outlined'|'elevated', leadingIcon, trailingIcon, color, shape, disabled |
Material button |
IconButton |
onPress, variant?: 'default'|'bordered'|'outlined', color, shape, disabled |
Icon-only button |
TextButton |
onPress, color, disabled, children |
Text-only button |
ToggleButton |
checked, onCheckedChange, text, variant?: 'default'|'icon'|'filledIcon'|'outlinedIcon', color |
Toggle button |
RadioButton |
selected, onClick |
Radio button |
| Component | Key Props | Description |
|---|---|---|
Switch |
value, label, variant?: 'checkbox'|'switch'|'button', onValueChange, color, elementColors |
Switch/checkbox |
TextInput |
defaultValue, onChangeText, multiline, numberOfLines, keyboardType, autocorrection, autoCapitalize |
Text input |
Slider |
value, steps, min, max, color, elementColors, onValueChange |
Value slider |
DateTimePicker |
initialDate, onDateSelected, variant?: 'picker'|'input', displayedComponents?: 'date'|'hourAndMinute'|'dateAndTime', is24Hour |
Date/time picker |
Picker |
options: string[], selectedIndex, onOptionSelected, variant?: 'segmented'|'radio', color |
Selection picker |
| Component | Key Props | Description |
|---|---|---|
Icon |
source, tintColor, size, contentDescription |
Icon display |
Card |
variant?: 'default'|'elevated'|'outlined', color, elementColors |
Material card |
Surface |
color, contentColor, tonalElevation, shadowElevation |
Material surface |
Divider |
— | Visual separator |
CircularProgress |
progress?: number|null, color |
Circular indicator |
LinearProgress |
progress?: number|null, color |
Linear indicator |
CircularWavyProgress |
progress?: number|null, color |
Wavy circular |
LinearWavyProgress |
progress?: number|null, color |
Wavy linear |
| Component | Key Props | Description |
|---|---|---|
Chip |
variant?: 'assist'|'filter'|'input'|'suggestion', label, leadingIcon, trailingIcon, selected, enabled, onPress, onDismiss |
Material chip |
FilterChip |
selected, label, enabled, onPress. Sub: .LeadingIcon, .TrailingIcon |
Filter chip |
| Component | Key Props | Description |
|---|---|---|
AlertDialog |
title, text, visible, confirmButtonText, dismissButtonText, onConfirmPressed, onDismissPressed |
Alert dialog |
BasicAlertDialog |
onDismissRequest, children |
Custom alert dialog |
ModalBottomSheet |
onDismissRequest, skipPartiallyExpanded, children |
Bottom sheet |
| Component | Key Props | Description |
|---|---|---|
ContextMenu |
color, style. Sub: .Trigger, .Preview, .Items |
Context menu |
SearchBar |
onSearch. Sub: .Placeholder, .ExpandedFullScreenSearchBar |
Search bar |
DockedSearchBar |
onQueryChange. Sub: .Placeholder, .LeadingIcon |
Docked search |
Carousel |
variant?: 'multiBrowse'|'unconstrained', itemSpacing, contentPadding, flingBehavior |
Carousel |
PullToRefreshBox |
isRefreshing, onRefresh |
Pull-to-refresh wrapper |
HorizontalFloatingToolbar |
variant?: 'standard'|'vibrant'. Sub: .FloatingActionButton |
Floating toolbar |
ListItem |
headline, supportingText, overlineText, onPress. Sub: .Leading, .Trailing, .SupportingContent |
List item |
Shape |
Static methods: .Star(), .PillStar(), .Pill(), .Circle(), .Rectangle(), .Polygon(), .RoundedCorner() |
Custom shapes |
Every component accepts a modifiers prop — an array of modifier configs. Modifiers are the primary way to style and configure views.
import { padding, background, buttonStyle } from '@expo/ui/swift-ui/modifiers';
<Button
label="Click me"
modifiers={[
padding({ all: 16 }),
background('#f0f0f0'),
buttonStyle('bordered'),
]}
/>Import from @expo/ui/swift-ui/modifiers.
| Modifier | Signature | Description |
|---|---|---|
frame |
({ width?, height?, minWidth?, maxWidth?, minHeight?, maxHeight?, idealWidth?, idealHeight?, alignment? }) |
Set dimensions |
padding |
({ top?, bottom?, leading?, trailing?, horizontal?, vertical?, all? }) |
Padding (no args = default) |
fixedSize |
({ horizontal?, vertical? }) |
Prevent view from shrinking |
offset |
({ x?, y? }) |
Translate position |
aspectRatio |
({ ratio, contentMode?: 'fit'|'fill' }) |
Aspect ratio constraint |
containerRelativeFrame |
({ axes, count?, span?, spacing?, alignment? }) |
Size relative to container (iOS 17+) |
ignoreSafeArea |
({ regions?, edges? }) |
Ignore safe area |
zIndex |
(index: number) |
Z-ordering |
layoutPriority |
(priority: number) |
Layout priority |
| Modifier | Signature | Description |
|---|---|---|
background |
(color, shape?) |
Background color with optional shape |
foregroundStyle |
(style) |
Color, gradient, or hierarchical style |
foregroundColor |
(color) |
Foreground color (deprecated, use foregroundStyle) |
tint |
(color) |
Tint color |
opacity |
(value: 0-1) |
Opacity |
cornerRadius |
(radius: number) |
Corner radius |
clipShape |
(shape, cornerRadius?) |
Clip to shape |
clipped |
(boolean) |
Clip to bounds |
border |
({ color, width? }) |
Border |
shadow |
({ radius, x?, y?, color? }) |
Shadow |
blur |
(radius) |
Blur |
brightness |
(amount) |
Brightness |
contrast |
(amount) |
Contrast |
saturation |
(amount) |
Saturation |
hueRotation |
(angle) |
Hue rotation (degrees) |
grayscale |
(amount: 0-1) |
Grayscale |
colorInvert |
(boolean) |
Invert colors |
luminanceToAlpha |
() |
Luminance to alpha |
overlay |
({ color?, alignment? }) |
Overlay |
backgroundOverlay |
({ color?, alignment? }) |
Background overlay |
mask |
(shape, cornerRadius?) |
Mask shape |
glassEffect |
({ glass?: { variant, interactive?, tint? }, shape?, cornerRadius? }) |
Glass effect (iOS 26+) |
glassEffectId |
(id, namespaceId) |
Glass effect identity |
| Modifier | Signature | Description |
|---|---|---|
scaleEffect |
(scale | { x, y }) |
Scale |
rotationEffect |
(angle) |
Rotation (degrees) |
rotation3DEffect |
({ angle, axis?, perspective? }) |
3D rotation |
matchedGeometryEffect |
(id, namespaceId) |
Matched geometry animation |
| Modifier | Signature | Description |
|---|---|---|
font |
({ family?, size?, weight?, design?: 'default'|'rounded'|'serif'|'monospaced' }) |
Font |
bold |
() |
Bold text |
italic |
() |
Italic text |
monospacedDigit |
() |
Fixed-width digits |
underline |
({ isActive, pattern, color? }) |
Underline |
strikethrough |
({ isActive, pattern, color? }) |
Strikethrough |
kerning |
(value) |
Character spacing |
lineSpacing |
(value) |
Line spacing |
lineLimit |
(limit) |
Max lines |
truncationMode |
('head'|'middle'|'tail') |
Truncation |
textCase |
('lowercase'|'uppercase') |
Text case |
textSelection |
(boolean) |
Selectable text |
multilineTextAlignment |
('center'|'leading'|'trailing') |
Text alignment |
allowsTightening |
(boolean) |
Allow character compression |
| Modifier | Signature | Description |
|---|---|---|
buttonStyle |
('automatic'|'bordered'|'borderedProminent'|'borderless'|'glass'|'glassProminent'|'plain') |
Button style |
toggleStyle |
('automatic'|'switch'|'button') |
Toggle style |
controlSize |
('mini'|'small'|'regular'|'large'|'extraLarge') |
Control size |
labelStyle |
('automatic'|'iconOnly'|'titleAndIcon'|'titleOnly') |
Label display style |
labelsHidden |
() |
Hide labels |
textFieldStyle |
('automatic'|'plain'|'roundedBorder') |
TextField style |
pickerStyle |
('automatic'|'inline'|'menu'|'navigationLink'|'palette'|'segmented'|'wheel') |
Picker style |
datePickerStyle |
('automatic'|'compact'|'graphical'|'wheel') |
DatePicker style |
progressViewStyle |
('automatic'|'linear'|'circular') |
ProgressView style |
gaugeStyle |
('automatic'|'circular'|'circularCapacity'|'linear'|'linearCapacity') |
Gauge style |
submitLabel |
('continue'|'done'|'go'|'join'|'next'|'return'|'route'|'search'|'send') |
Keyboard return key |
| Modifier | Signature | Description |
|---|---|---|
listStyle |
('automatic'|'plain'|'inset'|'insetGrouped'|'grouped'|'sidebar') |
List style |
listRowBackground |
(color) |
Row background |
listRowSeparator |
(visibility, edges?) |
Row separator visibility |
listRowInsets |
({ top?, leading?, bottom?, trailing? }) |
Row insets |
listSectionSpacing |
('default'|'compact'|number) |
Section spacing |
listSectionMargins |
({ length?, edges? }) |
Section margins (iOS 26+) |
scrollContentBackground |
('automatic'|'visible'|'hidden') |
Scroll background |
scrollDisabled |
(boolean) |
Disable scrolling |
scrollDismissesKeyboard |
('automatic'|'never'|'interactively'|'immediately') |
Keyboard dismiss on scroll |
moveDisabled |
(boolean) |
Disable reorder |
deleteDisabled |
(boolean) |
Disable delete |
headerProminence |
('standard'|'increased') |
Header prominence |
| Modifier | Signature | Description |
|---|---|---|
gridCellColumns |
(count) |
Column span |
gridCellUnsizedAxes |
(axes) |
Unsized axes |
gridColumnAlignment |
(alignment) |
Column alignment |
gridCellAnchor |
(anchor) |
Cell anchor |
| Modifier | Signature | Description |
|---|---|---|
onTapGesture |
(handler) |
Tap handler |
onLongPressGesture |
(handler, minimumDuration?) |
Long press |
onAppear |
(handler) |
View appeared |
onDisappear |
(handler) |
View disappeared |
refreshable |
(handler: () => Promise<void>) |
Pull to refresh |
| Modifier | Signature | Description |
|---|---|---|
disabled |
(boolean) |
Disable view |
hidden |
(boolean) |
Hide view |
tag |
(string | number) |
Set tag (for Picker options) |
badge |
(value?) |
Badge text |
badgeProminence |
('standard'|'increased'|'decreased') |
Badge prominence |
environment |
(key, value) |
Set environment (editMode, colorScheme) |
animation |
(animationType, animatedValue) |
Animation |
contentTransition |
(type, params?) |
Content transition animation |
presentationDetents |
(detents, options?) |
Sheet heights |
presentationDragIndicator |
(visibility) |
Sheet drag indicator |
presentationBackgroundInteraction |
(interaction) |
Sheet background interaction |
interactiveDismissDisabled |
(boolean) |
Prevent sheet dismissal |
menuActionDismissBehavior |
('automatic'|'disabled'|'enabled') |
Menu dismiss behavior |
| Modifier | Signature | Description |
|---|---|---|
accessibilityLabel |
(label) |
Accessibility label |
accessibilityHint |
(hint) |
Accessibility hint |
accessibilityValue |
(value) |
Accessibility value |
import { shapes } from '@expo/ui/swift-ui/modifiers';
shapes.roundedRectangle({ cornerRadius: 12 })
shapes.capsule()
shapes.rectangle()
shapes.ellipse()
shapes.circle()Import from @expo/ui/jetpack-compose/modifiers.
| Modifier | Signature | Description |
|---|---|---|
paddingAll |
(all: number) |
Equal padding all sides |
padding |
(start, top, end, bottom) |
Individual padding (RTL-aware) |
size |
(width, height) |
Exact size |
width |
(value) |
Exact width |
height |
(value) |
Exact height |
fillMaxSize |
(fraction?) |
Fill available space |
fillMaxWidth |
(fraction?) |
Fill available width |
fillMaxHeight |
(fraction?) |
Fill available height |
wrapContentWidth |
(alignment?) |
Wrap width to content |
wrapContentHeight |
(alignment?) |
Wrap height to content |
offset |
(x, y) |
Position offset |
background |
(color) |
Background color |
border |
(borderWidth, borderColor) |
Border |
shadow |
(elevation) |
Shadow/elevation |
alpha |
(value: 0-1) |
Opacity |
blur |
(radius) |
Blur effect |
rotate |
(degrees) |
Rotation |
zIndex |
(index) |
Z-ordering |
weight |
(weight) |
Flex weight (in Row/Column) |
align |
(alignment) |
Alignment in container |
matchParentSize |
() |
Match Box parent size |
animateContentSize |
(dampingRatio?, stiffness?) |
Animate size changes |
clickable |
(handler) |
Click handler |
selectable |
(selected, handler) |
Selectable item |
clip |
(shape) |
Clip to shape |
testID |
(tag) |
Test identifier |
import { Shapes } from '@expo/ui/jetpack-compose/modifiers';
Shapes.Rectangle
Shapes.Circle
Shapes.RoundedCorner(12) // or { topStart: 12, topEnd: 12, bottomStart: 0, bottomEnd: 0 }
Shapes.CutCorner(8)
Shapes.Material.Heart
Shapes.Material.Pill
Shapes.Material.Diamond
// Many more Material shapes availableThe foregroundStyle modifier is versatile and supports multiple style types:
import { foregroundStyle } from '@expo/ui/swift-ui/modifiers';
// Simple color
foregroundStyle('red')
foregroundStyle('#FF6B35')
// Color object
foregroundStyle({ type: 'color', color: '#FF6B35' })
// Hierarchical (semantic, respects system theme)
foregroundStyle({ type: 'hierarchical', style: 'primary' })
foregroundStyle({ type: 'hierarchical', style: 'secondary' }) // lighter/subtle
foregroundStyle({ type: 'hierarchical', style: 'tertiary' })
foregroundStyle({ type: 'hierarchical', style: 'quaternary' })
// Linear gradient
foregroundStyle({
type: 'linearGradient',
colors: ['#FF0000', '#0000FF'],
startPoint: { x: 0, y: 0 },
endPoint: { x: 1, y: 1 },
})
// Radial gradient
foregroundStyle({
type: 'radialGradient',
colors: ['#FF0000', '#0000FF'],
center: { x: 0.5, y: 0.5 },
startRadius: 0,
endRadius: 100,
})import { Button, Form, Host, HStack, Image, Section, Spacer, Text, Toggle } from '@expo/ui/swift-ui';
import { background, buttonStyle, clipShape, foregroundStyle, frame } from '@expo/ui/swift-ui/modifiers';
<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 />
<Toggle isOn={isAirplaneMode} onIsOnChange={setIsAirplaneMode} />
</HStack>
</Section>
</Form>
</Host>import { Picker, Text } from '@expo/ui/swift-ui';
import { pickerStyle, tag } from '@expo/ui/swift-ui/modifiers';
<Picker label="Size" modifiers={[pickerStyle('menu')]}
selection={selectedIndex} onSelectionChange={setSelectedIndex}>
{options.map((option, index) => (
<Text key={index} modifiers={[tag(index)]}>{option}</Text>
))}
</Picker><Button label="Default" />
<Button label="Glass" modifiers={[buttonStyle('glass')]} />
<Button label="Bordered" modifiers={[buttonStyle('bordered')]} />
<Button label="Destructive" role="destructive" />
<Button label="Small" modifiers={[controlSize('small'), buttonStyle('bordered')]} />
<Button label="Icon Only" systemImage="gear" modifiers={[buttonStyle('glass'), labelStyle('iconOnly')]} /><Button>
<VStack spacing={4}>
<Image systemName="folder" />
<Text>Folder</Text>
</VStack>
</Button>import { BottomSheet, Text, Button } from '@expo/ui/swift-ui';
import { presentationDetents, presentationDragIndicator } from '@expo/ui/swift-ui/modifiers';
<BottomSheet isPresented={isOpen} onIsPresentedChange={setIsOpen}>
<Text modifiers={[presentationDetents([{ type: 'medium' }, { type: 'large' }]),
presentationDragIndicator('visible')]}>
Sheet content
</Text>
</BottomSheet>import { glassEffect, padding } from '@expo/ui/swift-ui/modifiers';
<Text modifiers={[
padding({ all: 16 }),
glassEffect({ glass: { variant: 'clear' } }),
]}>
Glass effect text
</Text>import { Button, Host } from '@expo/ui/jetpack-compose';
<Host matchContents>
<Button variant="outlined" onPress={() => {}} leadingIcon="add">
Add Item
</Button>
</Host>import { CircularProgress, LinearProgress } from '@expo/ui/jetpack-compose';
<CircularProgress progress={0.7} color="#6200EE" />
<LinearProgress progress={null} /> {/* indeterminate */}You can create custom SwiftUI components that work with the modifier system:
- Create a local Expo module:
npx create-expo-module@latest --local my-ui - Add
ExpoUIpod dependency - Extend
UIBaseViewPropsfor automatic modifier support - Conform to
ExpoSwiftUI.Viewprotocol - Register with
ExpoUIView(MyView.self) - Use
createViewModifierEventListenerin JS wrapper
- Create Swift struct conforming to
ViewModifierandRecord - Register with
ViewModifierRegistry.register("name")inOnCreate - Create JS helper with
createModifier('name', params)
// JS side
import { createModifier } from '@expo/ui/swift-ui/modifiers';
export const customBorder = (params) => createModifier('customBorder', params);Use Metro's platform extensions to keep web unchanged while adding native UI:
app/index.tsx— web (original, fallback)app/index.ios.tsx— iOS (SwiftUI)app/index.android.tsx— Android (Jetpack Compose)
Metro resolves .ios.tsx > .android.tsx > .tsx automatically. No renaming of existing files needed.
Section headers apply a secondary/grey foregroundStyle to ALL content inside the header prop — overriding any foregroundStyle you set on individual Text elements. To get primary-colored text in a section header, add headerProminence('increased') to the Section's modifiers:
<Section
header={<Text modifiers={[font({ size: 32, weight: 'bold' })]}>Title</Text>}
modifiers={[headerProminence('increased')]}
>You can pass SwiftUI system color names as strings directly:
foregroundStyle('label')— primary text (black/white)foregroundStyle('secondaryLabel')— ~60% grayforegroundStyle('tertiaryLabel')— ~30% grayforegroundStyle('quaternaryLabel')— ~18% gray
These are different from the hierarchical form ({ type: 'hierarchical', style: 'secondary' }) which derives from the parent's foreground color.
Text inside SwiftUI Button with HStack will center-align when wrapping to multiple lines. Add multilineTextAlignment('leading') to keep it left-aligned:
<Button modifiers={[buttonStyle('bordered'), controlSize('large')]}>
<HStack>
<Text modifiers={[multilineTextAlignment('leading')]}>Long option text here</Text>
<Spacer />
</HStack>
</Button>BottomSheet is a sibling to other content inside the same Host. Wrap content in Group and apply presentation modifiers to the Group:
<Host style={{ flex: 1 }}>
<ScrollView>{/* main content */}</ScrollView>
<BottomSheet isPresented={show} onIsPresentedChange={setShow}>
<Group modifiers={[presentationDetents(['large']), presentationDragIndicator('visible')]}>
<ScrollView>{/* sheet content */}</ScrollView>
</Group>
</BottomSheet>
</Host>Do NOT combine <Host matchContents> with fillMaxWidth() on the root Compose child. matchContents measures intrinsic size while fillMaxWidth() wants to fill the parent — this conflict causes content to clip or overflow. Instead, either:
- Use
<Host style={{ flex: 1 }}>(no matchContents) for full-screen layouts - Use
matchContentsonly for inline/wrapped content without fill modifiers
Instead of mixing RN ScrollView + multiple Host wrappers, use a single Host with LazyColumn and contentPadding:
<Host style={{ flex: 1 }}>
<LazyColumn
verticalArrangement={{ spacedBy: 12 }}
contentPadding={{ start: 20, end: 20, top: 48, bottom: 40 }}
>
<Text style={{ typography: 'headlineLarge' }}>Title</Text>
<Card variant="outlined" modifiers={[fillMaxWidth()]}>{/* ... */}</Card>
</LazyColumn>
</Host>This avoids layout measurement issues and eliminates all RN imports.
Android BottomSheet must be conditionally rendered and wrapped in its own Host matchContents. Its children are RN views by default; nest another Host inside for Compose content:
{visible && (
<Host matchContents>
<BottomSheet onDismissRequest={() => setVisible(false)} skipPartiallyExpanded>
<Host style={{ flex: 1 }}>
<LazyColumn>{/* Compose quiz content */}</LazyColumn>
</Host>
</BottomSheet>
</Host>
)}Extract stateful logic (e.g., quiz state machine) into a shared hook so platform-specific screens only differ in rendering:
// hooks/use-quiz.ts — shared by iOS, Android, web
export function useQuiz(categoryId: CategoryId, visible: boolean) {
// state, handlers, helpers
return { question, handleSelect, handleNext, getOptionState, ... };
}| Aspect | SwiftUI | Jetpack Compose |
|---|---|---|
| Import | @expo/ui/swift-ui |
@expo/ui/jetpack-compose |
| Horizontal layout | HStack |
Row |
| Vertical layout | VStack |
Column |
| Toggle/Switch | Toggle |
Switch |
| Text input | TextField / SecureField |
TextInput |
| Icons | Image (SF Symbols via systemName) |
Icon (XML vectors, URIs) |
| Progress | ProgressView |
CircularProgress / LinearProgress |
| Form container | Form + Section |
No direct equivalent |
| Button styling | Via buttonStyle modifier |
Via variant prop |
| Modifiers | ~100 modifiers, rich styling | ~25 modifiers, layout-focused |
| Platform | iOS, tvOS, macOS | Android |