Split phone screen showing native iOS and Android interfaces with a relaxed red Maine Coon cat nearby

Expo UI: Real SwiftUI and Jetpack Compose, Straight From React

The Orange Cat
The Orange Cat

For its entire history, React Native has rendered most of its "UI" as JavaScript components styled with flexbox, or thin wrappers over a small set of host views. Anything richer — a real iOS form, a native context menu, a SwiftUI glass effect, Material 3 theming — meant writing native code or reaching for a community library that approximates the platform look. The results were often "almost native," and they tended to drift away from the platform as iOS and Android evolved.

@expo/ui takes the opposite approach. It exposes Apple's SwiftUI and Google's Jetpack Compose directly to your React tree. A <Switch> is a genuine SwiftUI Toggle or Compose Switch. A <Picker> is a real native picker. You write React, and the bridge renders actual platform widgets — with the system's animations, accessibility, dynamic type, haptics, and theming included at no extra cost. It's the difference between a convincing imitation and the real thing.

Why This Matters

Both Apple and Google have moved their recommended UI stacks to declarative frameworks, and declarative is exactly how React thinks. That alignment is what makes Expo UI possible, and it unlocks a few things that were genuinely painful before:

  • Truly native widgets. Correct animations, accessibility, haptics, and system theming, rather than a JS reimplementation that chases the platform from behind.
  • New OS features early. When iOS ships something like Liquid Glass (glassEffect) or Android exposes Material 3 dynamic color, you can reach it from JavaScript without waiting for a community port.
  • Drop-in replacements. As of SDK 56, Expo UI offers API-compatible swaps for eight popular community libraries, so you can shed maintenance-heavy JS UI packages in favor of first-party native ones.
  • Escape hatches. When the built-in coverage runs out, you can register your own custom SwiftUI views and modifiers and stay inside the React tree.

The library ships three entry points: @expo/ui/swift-ui for iOS and tvOS, @expo/ui/jetpack-compose for Android, and the universal @expo/ui, which delegates to SwiftUI on iOS, Compose on Android, and react-dom / react-native-web on web.

Getting It Into Your Project

Always install through expo install so the version lines up with your Expo SDK. Expo UI tracks the SDK number — SDK 56 means 56.x.

npx expo install @expo/ui
yarn expo install @expo/ui

Two requirements worth knowing up front. Expo UI needs the New Architecture, which has been mandatory since SDK 55, so for most modern apps that box is already checked. It also needs a development build for the native modules — the Android Compose components in particular are not available in Expo Go, though much of the iOS surface does run inside Expo Go.

One File For Every Platform

The universal API is the easiest place to start. You import from @expo/ui, and the library decides what to render per platform. Every Expo UI subtree has to be rooted in a <Host> — a container that owns the native rendering context and bridges it back into the regular React Native layout.

import { Host, Column, Button, Text } from '@expo/ui';

export default function Example() {
  return (
    <Host style={{ flex: 1 }}>
      <Column spacing={12} alignment="center">
        <Text>Hello, world!</Text>
        <Button label="Press me" onPress={() => alert('Pressed')} />
      </Column>
    </Host>
  );
}

The key mental shift happens inside the Host: you stop using flexbox. Layout there is done with native primitives — Column, Row, and Spacer in the universal API. The Host itself still participates in normal React Native flexbox from the outside, which is why <Host style={{ flex: 1 }}> works as the boundary between the two worlds. If you'd rather have the host size itself to its content, use <Host matchContents>.

Reaching For The Real SwiftUI

When you target iOS specifically, the @expo/ui/swift-ui entry point gives you a closer-to-the-metal SwiftUI surface, including its layout primitives (VStack, HStack, Spacer) and a modifiers system that mirrors SwiftUI's own view modifiers.

import { Host, VStack, HStack, CircularProgress, Button } from '@expo/ui/swift-ui';
import { padding, glassEffect } from '@expo/ui/swift-ui/modifiers';

export function Panel() {
  return (
    <Host style={{ flex: 1, margin: 32 }}>
      <VStack spacing={32}>
        <HStack spacing={32}>
          <CircularProgress />
          <CircularProgress color="orange" />
        </HStack>
        <Button
          label="Save changes"
          modifiers={[padding({ all: 16 }), glassEffect({ glass: { variant: 'clear' } })]}
        />
      </VStack>
    </Host>
  );
}

Modifiers are imported from @expo/ui/swift-ui/modifiers and passed as an array. They apply in order, exactly like chained SwiftUI modifiers, so padding then glassEffect reads the way it does in Swift. The available set mirrors SwiftUI closely: padding, background, frame, clipShape, foregroundStyle, font, buttonStyle, glassEffect, and more. Note that OS-gated features such as glassEffect require the corresponding iOS version to actually render their effect.

The Compose Side Of The House

On Android, @expo/ui/jetpack-compose renders real Compose components backed by Material 3.

import { Host, Button } from '@expo/ui/jetpack-compose';

export function SaveButton() {
  return (
    <Host matchContents>
      <Button onClick={() => alert('Saved!')}>Save changes</Button>
    </Host>
  );
}

Look closely and you'll notice the API is not identical across the two native targets. On iOS, Button takes a label prop and an onPress handler; on Compose, it takes children and an onClick handler. That asymmetry reflects the underlying frameworks honestly rather than pretending they're the same thing. If you target both platforms with the platform-specific entry points, you write platform files — Button.ios.tsx and Button.android.tsx. The universal @expo/ui API exists precisely to paper over this difference when you don't want to maintain two files.

Swapping Out Community Libraries

One of the most practical reasons to adopt Expo UI in SDK 56 is its set of drop-in replacements. For eight widely used community packages, Expo UI now offers API-compatible, natively backed equivalents — often you just change the import. The covered libraries include @gorhom/bottom-sheet, @react-native-community/datetimepicker, @react-native-menu/menu, react-native-pager-view, @react-native-picker/picker, @react-native-segmented-control/segmented-control, @react-native-community/slider, and @react-native-masked-view/masked-view.

// before
// import { BottomSheet } from '@gorhom/bottom-sheet';

// after — same shape, now backed by SwiftUI / Compose
import { BottomSheet } from '@expo/ui';

Because these are backed by real native widgets, a few props can differ from the originals, so it's worth reading the migration notes rather than assuming a perfect one-to-one match. Still, trading a stack of JS reimplementations for first-party native components is a strong upgrade for an Expo app that's already on the New Architecture.

Driving Native State And Going Custom

SDK 56 added deeper escape hatches for when the built-in component set runs out. You can register your own SwiftUI views and modifiers, and Expo UI handles layout sync, props, and events for you, so a bespoke control still lives inside the React tree instead of forcing you into a separate native module.

Three hooks round this out. useNativeState drives an ObservableObject on SwiftUI or a MutableState on Compose directly from JavaScript, so your custom native view can react to React state. useMaterialColors exposes Android's Material 3 dynamic color so your Compose UI participates in the system theme. And WorkletCallback gives you synchronous, UI-thread callbacks — useful for building flicker-free controlled inputs like a TextInput whose value is owned by JS.

import { Host, TextInput } from '@expo/ui';
import { useNativeState } from '@expo/ui';

export function NameField() {
  const [name, setName] = useNativeState('');

  return (
    <Host matchContents>
      <TextInput
        defaultValue={name}
        onChangeText={setName}
        placeholder="Your name"
      />
    </Host>
  );
}

The pattern here is the same one that makes the whole library work: React stays in charge of what should be on screen, and the native framework stays in charge of how it looks and feels. The bridge keeps the two synchronized so you rarely have to think about the seam.

A Few Things To Keep In Mind

Expo UI is opinionated, and a couple of its constraints are easy to trip over. There's no flexbox inside a Host — you switch to native stacks and columns, and mixing the two mental models is the most common stumbling block. The library is Expo-only and tied to the SDK's versioning, so it's not a drop-in for a bare React Native app without Expo modules. Component coverage is broad but still growing; it covers the common SwiftUI and Compose surface rather than the full frameworks, which is exactly what the custom-view extension support is there to soften. And the web target of the universal API is still experimental and likely to change, so it's not the place to ship critical web UI just yet.

It's worth being precise about maturity, because the reputation lags the reality. Expo UI spent roughly three SDK cycles in experimental and alpha status, but as of SDK 56 the native SwiftUI and Jetpack Compose APIs are stable. The only piece still marked experimental today is the web target.

When Expo UI Is The Right Call

Reach for Expo UI when you want UI that is genuinely indistinguishable from a native SwiftUI or Compose app — real animations, accessibility, haptics, and system theming — without writing Swift or Kotlin. It shines when you want new OS features early, when you're already on Expo plus the New Architecture and want to retire JS-reimplementation UI libraries, and when you like the idea of React's declarative model mapping onto the platforms' own declarative frameworks.

It's the wrong call when you need a bare non-Expo app, must support the legacy architecture, want pixel-identical cross-platform branding (a JS library like react-native-paper or tamagui fits that better), or depend on the web target being rock-solid today. The trade is clear: those libraries give you consistent-everywhere JS UI, while Expo UI gives you truly native, platform-correct UI rendered straight from React. If "looks exactly right on each platform" matters more than "looks identical on both," Expo UI is hard to beat.