Expo UI

@expo/ui is the closest thing in the ecosystem to what this project is for: every one of its views is a real platform control - a real Picker, a real BottomSheet, a real Gauge - rather than something drawn to look like one. It reaches them through requireNativeView('ExpoUI', ...), the same derivation registerExpoView already mirrors, so the whole surface is available here for the price of a table of names.

Import
import {
  UiHost,
  UiMenu,
  UiContextMenu,
  UiViewHost,
  UiButton,
  UiDivider,
  UiSlot,
  UiList,
  UiSwipeActions,
  UiVStack,
  UiHStack,
  UiSpacer,
  UiZStack,
  UiGroup,
  UiRectangle,
  UiRoundedRectangle,
  UiUnevenRoundedRectangle,
  UiCapsule,
  UiCircle,
  UiEllipse,
  UiAccessoryWidgetBackground,
  UiLabel,
  UiLink,
  UiSlider,
  UiStepper,
  UiToggle,
  UiTextField,
  UiColorPicker,
  UiGauge,
  UiChart,
  UiProgress,
  UiForm,
  UiSection,
  UiLabeledContent,
  UiImage,
  UiText,
  UiDatePicker,
  UiPicker,
  UiBottomSheet,
} from '@ng-native/expo';
Template
<ui-host><ui-menu><ui-context-menu><ui-view-host><ui-button><ui-divider><ui-slot><ui-list><ui-swipe-actions><ui-vstack><ui-hstack><ui-spacer><ui-zstack><ui-group><ui-rectangle><ui-rounded-rectangle><ui-uneven-rounded-rectangle><ui-capsule><ui-circle><ui-ellipse><ui-accessory-widget-background><ui-label><ui-link><ui-slider><ui-stepper><ui-toggle><ui-text-field><ui-color-picker><ui-gauge><ui-chart><ui-progress><ui-form><ui-section><ui-labeled-content><ui-image><ui-text><ui-date-picker><ui-picker><ui-bottom-sheet> once they are in the component's imports

Install

npx expo install @expo/ui react-dom

react-dom is installed with it at the SDK's version: @expo/ui has it as an optional peer, and npm otherwise takes the newest react-dom, whose React peer the app's React does not meet, so every later npm install stops with ERESOLVE.

import { registerExpoUiViews } from '@ng-native/expo';
import { UiHost, UiMenu, UiButton } from '@ng-native/expo/expo-ui-components';

The smallest thing that works

import { Platform } from 'react-native';
import { registerExpoUiViews } from '@ng-native/expo';

registerExpoUiViews(Platform.OS); // once, before the app mounts
<ui-host style="height: 44">
  <ui-slider [value]="volume()" (valueChanged)="volume.set($event.nativeEvent.value)" />
</ui-host>

Registering the views

registerExpoUiViews(platform) registers every @expo/ui view the current platform has, all at once - unlike the other register* functions in this package, because these are all one module: an app with @expo/ui installed has all of them, and an app without it has none. Call it once at startup with Platform.OS.

Element names are platform-neutral where both platforms have the control, so a template writes <ui-vstack> once and gets Compose's Column on Android. Where only one platform has a control - a Gauge is SwiftUI's, a SearchBar is Compose's - the element exists only there; registering for the wrong platform is an element that commits as nothing, which registerExpoUiViews avoids by reading the platform you pass it.

A component of your own keeps its ui- selector. A design system often picks the same prefix. An element a component of the app's is mounted on, a ui-button of your own, is a plain view drawing that component's template, whatever @expo/ui has under the name. The name is the native view where no component matches it, and for the typed components this package exports. A template that imports both your ui-button and UiButton is a compile error, as two components on one element always are.

<ui-host> is required

SwiftUI and Compose lay out their own subtrees. <ui-host> is the bridge from Yoga's layout to theirs; every other ui-* element must sit inside one, or the control has no size and does not appear - which looks exactly like a module that failed to install. UiHost's matchContents input sizes the host to the SwiftUI content instead of the other way round; ignoreSafeArea (iOS only) and useViewportSizeMeasurement are the other two host-level controls.

Names, and the typed components

Most @expo/ui views are, deliberately, names rather than components: there are ninety-odd of them, each with its own props and its own modifier system, and a component per view, prop for prop, would be a second place for every one of them to be wrong. The element plus the module's own documentation covers the common case:

<ui-gauge [value]="0.4" [modifiers]="[{ $type: 'frame', width: 80, height: 80 }]" />

The exception is strict templates, where an unknown element - and every prop on it - is a type error. expo-ui-components.ts has thin typed components for the views an app reaches for most:

  • UiHost - the bridge above.
  • UiMenu - a SwiftUI Menu. Its trigger is the label input or a <ui-slot name="label">; its items are children.
  • UiContextMenu - a SwiftUI contextMenu, the menu a long press opens, iOS only. Its content is three ui-slots: trigger, what is always shown; items, the menu's buttons; and preview, shown above the open menu when there is one. See A context menu.
  • UiViewHost - views of the app's own inside SwiftUI or Compose content, the way back from a ui-host: @expo/ui's RNHostView. It holds one element; matchContents sizes it to that element.
  • UiButton - a SwiftUI Button, as a menu item or on its own. role is 'default', 'cancel' or 'destructive'.
  • UiDivider - a separator between groups of menu items.
  • UiSlot - content for a named slot of its parent view, such as a menu's label. extraProps is what the slot tells its parent about itself, such as a swipe group's edge.
  • UiList - a SwiftUI List.
  • UiSwipeActions - the system's swipe actions on a list row, iOS only. The first child is the row, and each edge's actions are ui-buttons in a <ui-slot name="actions" [extraProps]="{ edge: 'trailing', allowsFullSwipe: true }">. A row in a scroll view that also scrolls sideways loses its swipes to that scroll view. A sideways drag on the row is the swipe's, so a pressable in a ui-view-host in the row does not press for it.
  • UiVStack and UiHStack - SwiftUI's stacks, with alignment and spacing, and UiSpacer for the room left over in one.
  • UiZStack - SwiftUI's ZStack: its children drawn over one another, the first at the back, placed by alignment.
  • UiGroup - SwiftUI's Group, iOS only: its children as they are, for modifiers that belong to several views at once.
  • UiRectangle, UiRoundedRectangle, UiUnevenRoundedRectangle, UiCapsule, UiCircle and UiEllipse - SwiftUI's shapes, iOS only, coloured with the foregroundStyle modifier. UiAccessoryWidgetBackground is the system's backdrop for a lock screen widget, for a widget layout: from @expo/ui 57.0.22 it has no view in the app, and expo-widgets 57.0.23 or later draws it in the widget.
  • UiLabel - a title beside an SF Symbol, iOS only. color colours the symbol alone, and the foregroundStyle modifier the title too. UiLink opens its destination URL when tapped, showing its label or the views written inside it, iOS only.
  • UiSlider, UiStepper and UiToggle - with valueChanged, valueChange and isOnChange for what the user did.
  • UiTextField - its text is a nativeState(''), which the field writes to on the UI thread; textChange reports each change.
  • UiColorPicker, UiGauge and UiProgress. A gauge's type is its gaugeStyle: circular, circularCapacity, linear, linearCapacity or automatic. Its currentValueLabel, minimumValueLabel and maximumValueLabel are drawn as text where SwiftUI puts each.
  • UiChart - a Swift Charts chart of data, iOS only, each point an x label or number against a y, with an optional color. type is line, point, bar, area, pie (iOS 17) or rectangle, and each type takes a style of its own: lineStyle, barStyle, pieStyle and so on. referenceLines draws lines across it in ruleStyle.
  • UiForm, UiSection and UiLabeledContent - settings-style grouped rows. A section's header is its title or a <ui-slot name="header">, and a <ui-slot name="footer"> is the note under its rows. When both a title and a header slot are set, @expo/ui's SectionView draws the title and drops the slot; an empty title="" counts as unset.
  • UiImage - an SF Symbol by systemName, a symbol or picture in the app's asset catalog by assetName, or a picture by uiImage, the URL of a file on the device. A symbol's size is a font modifier of that many points, which a font modifier of your own wins over, and its color a foregroundStyle modifier.
  • UiText - a SwiftUI Text. Its text is what is written inside it, <ui-text>Us {{ score() }}</ui-text>, as <text> takes its own; text sets it too, and wins. A ui-text nested inside one is a span with its own modifiers, drawn after the text: put text that comes after a span in a ui-text of its own. On iOS, a date drawn by dateStyle, or a timerInterval, has it keep time by itself, as timerInterval does a UiProgress: see Time between updates.
  • UiDatePicker - SwiftUI's DatePicker on iOS, Compose's on Android, and a Signal Forms field: its value model is a Date or null, whichever way each platform takes and reports one. A pick is when it emits touch. SwiftUI's picker always shows a date, today when the field is empty, and the report it makes as it appears is not taken as the user's choice, so an optional date is best asked for behind a button that shows the picker. Without a form, selection still takes ISO text on iOS. displayedComponents is a list of 'date' and 'hourAndMinute'; an empty list shows nothing on iOS, and the date and time on Android, which has no way to show nothing. range, an optional start and an optional end as ISO text, limits the dates that can be picked on both: from start on, up to end, or between the two. accessibilityLabel is what VoiceOver reads for it, and TalkBack with @expo/ui 57.0.21 or later. title is iOS only.
  • UiPicker - a SwiftUI Picker on iOS, Compose's segmented buttons or dropdown menu on Android, and a Signal Forms field. options draws the choices; see Choices: UiPicker below.
  • UiBottomSheet - the system's sheet on both platforms, opened by its open model, with the app's own components as its content. It needs no ui-host; see A bottom sheet below.
  • UiNavigationStack, UiToolbar and UiToolbarItem - SwiftUI's navigation bar and the items in it, iOS only. A toolbar's children are the view it belongs to, and its items go in <ui-slot name="content">, each a <ui-toolbar-item> with a placement. A UiButton with role="close" and no label is the system's own close button, from iOS 26. They need @expo/ui 57.0.20 or later in the native build. See The system's close button for one in a sheet.

Most inputs go straight through to the node as a prop of the same name, modifiers included - UiModifier is one SwiftUI modifier, shaped exactly as @expo/ui's own modifier functions build them ({ $type: 'frame', ... }). A few become what the native view reads instead, as @expo/ui's own components send them. On iOS, a ui-image's size and color are font and foregroundStyle modifiers, a ui-gauge's type is a gaugeStyle modifier and its labels are slots, a ui-label's color colours its icon, and accessibilityLabel on a ui-menu or ui-date-picker is an accessibilityLabel modifier; each modifier made this way comes after the app's own. A ui-view-host takes modifiers on Android only, since SwiftUI's view host reads none. Events are declared as outputs purely for their type: $event in a template binding is typed as the native payload, but the output itself is never emitted. Angular binds a template's (dateChange) to the element's own native event directly, so that is the only place it arrives - a programmatic subscription to the output from code never receives anything. Subscribe in the template, not to the output in code.

On Android

One template draws both platforms for UiHost, UiVStack, UiHStack, UiZStack, UiText, UiSpacer, UiToggle, UiSlider, UiButton, UiDivider, UiProgress and UiSlot. Compose names some props and events differently, and each of these components sends its inputs under the name the platform reads and delivers Compose's events through the same outputs, with the same $event shape: a toggle's isOn reaches Compose's switch as value, and its checkedChange arrives as (isOnChange) with nativeEvent.isOn. A button's label is drawn as text inside it, since Compose's button has no label of its own. A toggle's label is SwiftUI's alone: Compose's switch draws none, and the switch is the element, so on Android put a <ui-text> beside it in a <ui-hstack>. A button's systemImage and role are SwiftUI's alone too. A slider's steps is the number of steps on both, rounded to a whole number, with one exception: Compose has no slider that stops only at its two ends, so steps="1" slides freely there.

A toggle without isOn switches itself on both, and one bound to isOn shows what isOn says, so write the new value back from (isOnChange). A stack without an alignment is centred on both; a UiZStack is Compose's Box on Android. Without a spacing, SwiftUI puts its own default spacing between a stack's children and Compose puts none, so give a stack a spacing where the gap matters.

modifiers go to both platforms as they are written, but for a ui-view-host's, which go to Android only, and the two read different ones. Compose skips a modifier it does not know, silently, and knows few of SwiftUI's: frame, font and foregroundStyle do nothing on Android. Even a name both know can read different fields: SwiftUI's padding takes leading, trailing, horizontal, vertical and all, while Compose's reads only start, top, end and bottom. Choose the modifiers by platform, with nativePlatform() from @ng-native/fabric, where a view needs them on both.

A font modifier's family names one of the app's own faces as a <text> would, by the name it is registered under: { $type: 'font', family: 'Nunito Sans-700', size: 17 }. SwiftUI finds a face only by the PostScript name inside its file, so on iOS that family is committed as the PostScript name the build read from the face's file. See Fonts.

UiDatePicker, UiPicker and UiBottomSheet take both platforms, as described under each. The rest of the typed components are SwiftUI's. On Android a text field, a menu and an image are different controls rather than renamed ones, and UiList, UiForm, UiSection and the others have no Compose view at all; use Compose's own names for those.

Still call registerExpoUiViews alongside importing these. The component supplies the types; the registration is what makes the element commit as the SwiftUI or Compose view. Add a component here when a template wants one of the other views typed, from @expo/ui's own props for it - the rest stay names.

A context menu

A long press on the trigger lifts it over the dimmed screen and opens the menu beside it:

<ui-host [matchContents]="true">
  <ui-context-menu>
    <ui-slot name="trigger"><ui-text text="Invoice 1042" /></ui-slot>
    <ui-slot name="items">
      <ui-button label="Duplicate" systemImage="plus.square.on.square" (buttonPress)="copy()" />
      <ui-divider />
      <ui-button label="Delete" role="destructive" systemImage="trash" (buttonPress)="remove()" />
    </ui-slot>
  </ui-context-menu>
</ui-host>

The trigger is SwiftUI content, as everything inside a ui-host is: ui-text, ui-image, the stacks. A row drawn with the app's own components becomes one inside a <ui-view-host>, which hosts views of the app's own in SwiftUI content. The row is laid out and pressed as anywhere else, and a long press lifts it and opens the menu:

@for (row of list.window(); track row.slot) {
<view [virtualListRow]="row">
  <ui-host
    ignoreSafeArea="all"
    [matchContents]="{ vertical: true }"
    (layout)="rowWidth.set($event.nativeEvent.layout.width)"
  >
    <ui-context-menu>
      <ui-slot name="trigger">
        <ui-view-host [matchContents]="true">
          <pressable class="row" [style.width.px]="rowWidth()" (press)="open(row.item)">
            <text>{{ row.item.label }}</text>
          </pressable>
        </ui-view-host>
      </ui-slot>
      <ui-slot name="items">
        <ui-button
          label="Duplicate"
          systemImage="plus.square.on.square"
          (buttonPress)="copy(row.item)"
        />
        <ui-button
          label="Delete"
          role="destructive"
          systemImage="trash"
          (buttonPress)="remove(row.item)"
        />
      </ui-slot>
    </ui-context-menu>
  </ui-host>
</view>
}

<ui-view-host> holds one element, and matchContents sizes it to that element: its height, and its width too. Nothing stretches the row to the list's width as a <view> around it would, so the row is given one, here the width its ui-host is laid out at, kept in a signal (rowWidth = signal<number | undefined>(undefined)). Without it the row is as wide as its text; without matchContents it has the width and no height.

Each row has a host of its own, sized to the row's height. ignoreSafeArea="all" matters in a list: a host keeps clear of the safe area otherwise, and a row that scrolls under the home indicator is pushed over the one above it.

This is iOS's menu: ui-context-menu is SwiftUI's alone, and has no view on Android. Render the row on its own there, with a long press that opens Dialogs.choose():

@if (ios) {
<!-- the ui-host and ui-context-menu above -->
} @else {
<pressable class="row" (press)="open(row.item)" (longPress)="choose(row.item)">
  <text>{{ row.item.label }}</text>
</pressable>
}

Text field state: nativeState

TextFieldView and SecureFieldView declare their text prop as an ObservableState rather than a plain string, and what actually travels over the prop is the shared object's id, a number. Binding the string itself is the obvious thing to write, and it fails silently as FieldInvalidTypeException, logged rather than thrown - the field renders and simply ignores everything the app sets, which looks like it works because both fields manage their own text when the prop is absent, and only setting the value from Angular does nothing.

nativeState(initial) builds one of these shared objects and returns a NativeState<T>:

protected readonly name = nativeState('');
<ui-text-field [text]="name" (textChange)="typed.set($event.nativeEvent.value)" />

The typed UiTextField takes the state itself and sends its id down the prop. An element with no typed component, such as <secure-field>, takes the id directly: bind [text]="name?.id" there, not name. The state lives on the native side and both sides hold a reference, so writing to it moves the caret in a field that is already on screen, where a signal and a re-render would not - that is the whole reason the prop is shaped this way. get() reads the current value (a write is scheduled onto the UI thread, so it is not readable back until that has run - @expo/ui's own accessors behave the same way), set() writes it, and release() detaches from the native object; worth calling from DestroyRef for a field inside a list that comes and goes, since a state that lives as long as the app does not need one.

nativeState() returns null off a device, where there is no native module to hold the state - either binding then leaves the prop absent, which is the field's own unmanaged behavior rather than a crash.

Choices: UiPicker

UiPicker is a SwiftUI Picker on iOS: one choice out of a short, fixed list, drawn as a menu, a segmented control, a wheel or inline rows. On Android it is drawn with Compose's own controls, described below. It is a Signal Forms field as it stands, through its value model, so [formField] binds it like any other control:

import { Component, signal } from '@angular/core';
import { FormField, form, required } from '@angular/forms/signals';
import { UiHost, UiPicker, type UiPickerOption } from '@ng-native/expo/expo-ui-components';

@Component({
  selector: 'app-booking',
  imports: [FormField, UiHost, UiPicker],
  template: `
    <ui-host [matchContents]="true">
      <ui-picker label="Room" pickerStyle="menu" [options]="rooms" [formField]="f.room" />
    </ui-host>
  `,
})
export class Booking {
  protected readonly rooms: readonly UiPickerOption[] = [
    { value: 'single', label: 'Single' },
    { value: 'double', label: 'Double' },
    { value: 'suite', label: 'Suite' },
  ];
  private readonly booking = signal({ room: 'double' });
  protected readonly f = form(this.booking, (path) => required(path.room));
}

options is a list of UiPickerOptions, each a value (a string or a number) and the label the user reads. The picker draws one ui-text per option in its content slot, tagged with the option's value: the slot and the tag modifiers @expo/ui otherwise wants written out by hand. What the field holds is the chosen option's value, never its position in the list, and values should be distinct, since the value is what tells one option from another. Children still work alongside or instead of options, for anything richer than a label.

  • value - the chosen option's value, or null: what [formField] binds. Outside a form, [(value)] binds it to a signal.
  • label and systemImage - the picker's own title, and an SF Symbol name beside it.
  • pickerStyle - SwiftUI's picker style: 'automatic', 'menu', 'segmented', 'wheel', 'inline' or 'palette'. It becomes a pickerStyle modifier ahead of any in modifiers.
  • modifiers - SwiftUI modifiers on the picker. SwiftUI draws a segmented picker as a UISegmentedControl, which a font modifier does not reach, so UiPicker also sends each font of a segmented picker to the control itself, with its family, size and weight. That needs a development build: in Expo Go the segments stay in the system font.
  • disabled - adds SwiftUI's disabled modifier. A form's disabled() rule sets it, and so does a bare disabled attribute outside a form.
  • touch - the user picked. A picker has no blur, so a pick is when the field counts as touched and a form shows its errors.
  • selectionChange - the native event, typed as { selection }. Like every event here, it arrives through a template binding only: (selectionChange)="$event.nativeEvent.selection".

A pick writes the new value into the model, and so into the form, before touch; a report whose selection is neither a string nor a number is ignored.

On Android Compose has no Picker, so the same element draws its options with Compose's own controls, and the field binds the same way:

  • pickerStyle: 'segmented' is a row of segmented buttons, the chosen one marked.
  • Any other style is an exposed dropdown menu: the chosen option, or label before there is one, with an arrow, and a tap opens the options under it.

systemImage, modifiers and children are SwiftUI's alone, and are not drawn on Android. The Compose pieces are components of their own too, for a layout UiPicker does not cover: UiSingleChoiceSegmentedButtonRow, UiSegmentedButton, UiExposedDropdownMenuBox, UiExposedDropdownMenu and UiDropdownMenuItem, each Android only. Like every ui-* view, the picker sits inside a <ui-host>.

A bottom sheet

UiBottomSheet is the system's sheet, sliding up over the screen it is opened from: SwiftUI's on iOS and Material's on Android. It is opened from a component, with no route:

import { Component, signal } from '@angular/core';
import { Pressable, Text, View } from '@ng-native/components';
import { UiBottomSheet } from '@ng-native/expo/expo-ui-components';

@Component({
  selector: 'app-results',
  imports: [Pressable, Text, UiBottomSheet, View],
  template: `
    <pressable (press)="sorting.set(true)"><text>Sort</text></pressable>

    <ui-bottom-sheet [(open)]="sorting" fitToContents (dismissed)="search()">
      <view class="sheet">
        <pressable (press)="sortBy('price')"><text>Price</text></pressable>
        <pressable (press)="sortBy('distance')"><text>Distance</text></pressable>
      </view>
    </ui-bottom-sheet>
  `,
})
export class Results {
  protected readonly sorting = signal(false);
  protected readonly order = signal('price');

  protected sortBy(order: string): void {
    this.order.set(order);
    this.sorting.set(false);
  }

  protected search(): void {
    // Runs once the sheet has gone, however it was closed.
  }
}

What is written inside the element is the sheet's content, and it is the app's own components, laid out and pressed as anywhere else. <ui-bottom-sheet> needs no <ui-host> around it, takes no room where it is written, and can be written anywhere in a template: the sheet covers the screen whichever view it is inside.

  • open - whether the sheet is on screen. Setting it true presents the sheet and setting it false dismisses it. A swipe down dismisses it too, as do a tap outside the sheet and the back button on Android, and each writes false back, so bind it both ways: [(open)]. With interactiveDismissDisabled none of them does.
  • fitToContents - the sheet is as tall as its content. Without it the sheet rests at half the screen's height and drags up to all of it. Whether a sheet is sized to its content is read as it opens, so a change to this while the sheet is open applies the next time.
  • detents - the heights the sheet rests at, on iOS only: a list of UiPresentationDetents, each 'medium' (about half the screen), 'large' (all of it), { fraction: 0.4 } (a fraction of the screen's height, from 0 to 1) or { height: 320 } (points). They are SwiftUI's presentationDetents, named and shaped as @expo/ui's. The sheet opens at the first and drags between them, and a change while it is open is followed. On iOS a sheet given detents rests at them rather than at the height of its content, whatever fitToContents says. Android takes no notice of detents: Compose's sheet has a half and a full height and no others, so there the sheet opens as fitToContents alone says. None, or an empty list, is the sheet without them.
  • showDragIndicator - whether the grabber shows at the top of the sheet. It does unless this is false.
  • backgroundColor - the colour of the sheet behind its content: SwiftUI's presentationBackground on iOS, where a sheet below its full height is otherwise drawn as glass, and the container colour of Compose's sheet on Android. A background-color on the element from CSS colours the sheet the same way, from a class, a stylesheet, a Tailwind utility or [style], and follows a token or a theme change. The input wins over CSS. With neither, the system draws the sheet as it does by default.
  • interactiveDismissDisabled - the user cannot dismiss the sheet, and only setting open false closes it. On iOS this is SwiftUI's interactiveDismissDisabled: a swipe down takes the sheet to its smallest detent and no further, and a tap outside it does nothing. On Android a swipe, the back button and a tap outside the sheet are all turned off, as @expo/ui's own sheet does it. Compose's sheet has no way to keep the drag between its half and full heights without the swipe that dismisses it, so on Android that drag is off too.
  • backgroundInteraction - whether the screen behind the sheet takes touches, on iOS 16.4 and later only: SwiftUI's presentationBackgroundInteraction. 'enabled' always, 'disabled' never, 'automatic' as the system decides, and { upThrough: 'medium' } (any UiPresentationDetent) while the sheet rests at that detent or below it, which should be one of its detents. Unset, SwiftUI decides. Android has no equivalent: Compose's sheet is shown in a window of its own over the screen, and the screen takes no touch while it shows.
  • modifiers - more SwiftUI modifiers, on iOS only: presentation modifiers such as presentationCornerRadius, and modifiers for the content such as ignoreSafeArea. Where the build has SwiftUI's toolbar and the sheet is not sized to its content, the content is in a ui-navigation-stack, which sets up the safe area again for what is inside it, so the modifiers go on the ui-zstack inside the stack. There a content modifier such as background or padding applies below the sheet's bar, where it has one. Otherwise they go after the sheet's own on the ui-group around its content. Android takes no notice of them.
  • dismissed - the sheet has finished closing, whether the user dismissed it or open was set false. It is emitted after the sheet has slid away, which is when another sheet or a dialog can be presented.

The content is in the tree from when the sheet opens until it has finished closing, and out of it the rest of the time. Its components are created with the page, as projected content always is. Where they should be created as the sheet opens instead, wrap the content in an @if on a signal of its own, set as the sheet opens and cleared in (dismissed):

<ui-bottom-sheet [(open)]="sorting" (dismissed)="options.set(false)">
  @if (options()) {
  <app-sort-options />
  }
</ui-bottom-sheet>

An @if on the signal bound to open would destroy the content the moment the sheet starts to close, and the sheet would slide away empty.

With fitToContents the content is laid out at the window's width and its own height. Without it the content fills the sheet.

The system's close button

A <ui-toolbar-item> written inside a sheet is not part of its content. On iOS it goes in a toolbar of the sheet's own, in a bar across the top of the sheet, which is where the system's close button belongs:

<ui-bottom-sheet [(open)]="sorting">
  <ui-toolbar-item placement="cancellationAction">
    <ui-button role="close" (buttonPress)="sorting.set(false)" />
  </ui-toolbar-item>
  <app-sort-options />
</ui-bottom-sheet>

An item holds SwiftUI content, as everything inside a ui-host is: a ui-button, or a ui-text as a title with placement="principal". placement takes SwiftUI's ToolbarItemPlacement names, and an item with none is placed by SwiftUI. The button does not close the sheet by itself: its (buttonPress) sets open.

In a build with SwiftUI's toolbar, a sheet that is not sized to its content has its content in a ui-navigation-stack, under the group that carries the presentation modifiers, over a ui-toolbar that holds the items. It has them with no item too, when the stack draws no bar, so items can come and go while the sheet is open and its content stays as it is. A sheet sized to its content with no item has neither: adding the first item to one that is open, or removing the last, makes its content again.

A sheet with a toolbar is not sized to its content on iOS, whatever fitToContents says: the bar is no part of the content. It rests at half and full height, or at its detents, so give it a { height } detent where it should be shorter.

Compose's sheet has no toolbar, so Android draws no item: give an Android sheet a close button in its content. An iOS build whose @expo/ui is older than 57.0.20 has no toolbar either, which includes Expo Go until its own copy catches up. There the sheet warns once in the console and is shown without the item.

The two platforms drive a sheet differently, and the component is where that ends. SwiftUI's BottomSheetView stays in the tree and is presented by its isPresented prop; it reports isPresentedChange when the user dismisses it and dismiss once it has gone. Compose's ModalBottomSheetView shows for as long as it is in the tree and reports dismissRequest; when open turns false the component calls the view's hide() and takes it out of the tree once it has slid away. The grabber is SwiftUI's presentationDragIndicator modifier and Compose's showDragHandle prop. Inside the element the sheet is wrapped as @expo/ui's own component wraps it: a ui-host, the sheet, on iOS a ui-group carrying the presentation modifiers, and a ui-view-host holding one view around the content.

A map with a sheet resting over it, which stays up and leaves the map to be panned while it is at half height, is a sheet that cannot be dismissed and lets the screen behind it take touches up through that detent:

<expo-map style="flex: 1" />
<ui-bottom-sheet
  [(open)]="stops"
  [detents]="['medium', 'large']"
  interactiveDismissDisabled
  [backgroundInteraction]="{ upThrough: 'medium' }"
>
  <app-route-stops />
</ui-bottom-sheet>

On Android the same sheet stays up, but the map behind it cannot be panned while it shows.

The sheet's own native events stop at the component. A (dismiss) listener on a view around it hears nothing from the sheet.

registerExpoUiViews registers the sheet's native view a second time as ui-bottom-sheet-view, the name the component's template uses for it, since ui-bottom-sheet there would be the component again. A template that does not import UiBottomSheet still gets the native view from <ui-bottom-sheet>, with each platform's own props.

Without the module

An element registered for @expo/ui when it is not installed commits as nothing (UnimplementedNativeView). nativeState() returns null. A UiBottomSheet shows nothing, and closes without waiting for a sheet to slide away.

Reference

ui-hostcomponent@ng-native/expo
import { UiHost } from '@ng-native/expo';and add UiHost to the component's imports

The bridge from Yoga's layout to SwiftUI's; every other ui-* view goes inside one. matchContents sizes the host to the SwiftUI content rather than the other way round, on both axes or on the ones named. Native reads a flag per axis, which is what this splits it into, as @expo/ui 's own Host does: without them the host has no size of its own, and the control drawn in it spills outside it, where it can be seen but not touched or read by VoiceOver.

Inputs

matchContents
boolean | { readonly vertical?: boolean; readonly horizontal?: boolean }

ignoreSafeArea
'all' | 'keyboard' | 'container'

The safe area SwiftUI's host ignores, iOS only: Compose's host has no such prop.

useViewportSizeMeasurement
boolean

ui-menucomponent@ng-native/expo
import { UiMenu } from '@ng-native/expo';and add UiMenu to the component's imports

A SwiftUI Menu . Its trigger is the label or a <ui-slot name="label"> ; its items are children.

Inputs

label
string

systemImage
string

An SF Symbol name.

accessibilityLabel
string

What VoiceOver reads for the menu's trigger: SwiftUI's accessibilityLabel modifier.

modifiers
readonly UiModifier[]

ui-context-menucomponent@ng-native/expo
import { UiContextMenu } from '@ng-native/expo';and add UiContextMenu to the component's imports

A SwiftUI contextMenu : the menu a long press on its trigger opens, with the trigger lifted over the dimmed screen. iOS only.

Inputs

modifiers
readonly UiModifier[]

ui-view-hostcomponent@ng-native/expo
import { UiViewHost } from '@ng-native/expo';and add UiViewHost to the component's imports

Views of the app's own inside SwiftUI or Compose content: the way back from a ui-host . What it holds is laid out by the engine, as anywhere else, and takes presses as anywhere else, so a row drawn with the app's own components can be a context menu's trigger or a sheet's body.

Inputs

matchContents
boolean

layoutRoot
boolean

For content presented in a window of its own, a sheet or a popover, where nothing above it delivers its touches: it then takes them itself, and is measured from itself.

modifiers
readonly UiModifier[]

Compose's modifiers for the view host, Android only: SwiftUI's view host takes none.

ui-buttoncomponent@ng-native/expo
import { UiButton } from '@ng-native/expo';and add UiButton to the component's imports

A SwiftUI Button , as a menu item or on its own, or Compose's on Android, which draws label as text inside it. systemImage and role are SwiftUI's alone. A close button with no label and no content is the system's own close button, from iOS 26.

Inputs

label
string

systemImage
string

An SF Symbol name.

role
'default' | 'cancel' | 'destructive' | 'close'

target
string

In a home-screen widget's or a Live Activity's layout, what a tap on it hands the app: see @ng-native/expo/widget and @ng-native/expo/live-activity . The layout's build reads it, so an app's own button is not sent it: @expo/ui 's button there has no target.

modifiers
readonly UiModifier[]

Outputs

buttonPress
NativeSyntheticEvent<Record<string, never>>

ui-dividercomponent@ng-native/expo
import { UiDivider } from '@ng-native/expo';and add UiDivider to the component's imports

A SwiftUI Divider , or Compose's HorizontalDivider : a separator between groups of items.

Inputs

modifiers
readonly UiModifier[]

ui-slotcomponent@ng-native/expo
import { UiSlot } from '@ng-native/expo';and add UiSlot to the component's imports

Content for a named slot of its parent view, such as a menu's label . extraProps is what the slot tells its parent about itself: a ui-swipe-actions group's edge and allowsFullSwipe .

Inputs

namerequired
string

extraProps
Readonly<Record<string, unknown>>

What the slot tells its SwiftUI parent about itself, iOS only.

ui-listcomponent@ng-native/expo
import { UiList } from '@ng-native/expo';and add UiList to the component's imports

A SwiftUI List : rows as SwiftUI lays them out, which is what lets a row take the system's own swipe actions through ui-swipe-actions .

Inputs

modifiers
readonly UiModifier[]

ui-swipe-actionscomponent@ng-native/expo
import { UiSwipeActions } from '@ng-native/expo';and add UiSwipeActions to the component's imports

The system's swipe actions on a list row, iOS only. The first child is the row; the actions go in <ui-slot name="actions" [extraProps]="{ edge: 'trailing', allowsFullSwipe: true }"> , one slot per edge, as ui-button s. A sideways drag on the row is the swipe's, so a pressable in a ui-view-host in the row does not press for it.

Inputs

modifiers
readonly UiModifier[]

ui-vstackcomponent@ng-native/expo
import { UiVStack } from '@ng-native/expo';and add UiVStack to the component's imports

A SwiftUI VStack , or Compose's Column on Android, centred, as SwiftUI's is, unless alignment says otherwise. Without spacing , SwiftUI puts its own default spacing between children and Compose puts none.

Inputs

alignment
'leading' | 'center' | 'trailing'

spacing
number

modifiers
readonly UiModifier[]

ui-hstackcomponent@ng-native/expo
import { UiHStack } from '@ng-native/expo';and add UiHStack to the component's imports

A SwiftUI HStack , or Compose's Row on Android, centred, as SwiftUI's is, unless alignment says otherwise. Compose has no firstTextBaseline , and centres instead. Without spacing , SwiftUI puts its own default spacing between children and Compose puts none.

Inputs

alignment
'top' | 'center' | 'bottom' | 'firstTextBaseline'

spacing
number

modifiers
readonly UiModifier[]

ui-spacercomponent@ng-native/expo
import { UiSpacer } from '@ng-native/expo';and add UiSpacer to the component's imports

A SwiftUI Spacer : the room left over in its stack.

Inputs

modifiers
readonly UiModifier[]

ui-zstackcomponent@ng-native/expo
import { UiZStack } from '@ng-native/expo';and add UiZStack to the component's imports

A SwiftUI ZStack : its children drawn over one another, the first at the back. On Android, Compose's Box , centred, as SwiftUI's is, unless alignment says otherwise.

Inputs

alignment
UiZStackAlignment

modifiers
readonly UiModifier[]

ui-groupcomponent@ng-native/expo
import { UiGroup } from '@ng-native/expo';and add UiGroup to the component's imports

A SwiftUI Group , iOS only: its children as they are, with no layout of its own. What it is for is modifiers that belong to several views at once, or to content that takes none itself, as a sheet's presentation modifiers go on a group around a ui-view-host .

Inputs

modifiers
readonly UiModifier[]

ui-rectanglecomponent@ng-native/expo
import { UiRectangle } from '@ng-native/expo';and add UiRectangle to the component's imports

A SwiftUI Rectangle , filling the space it is given. Colour it with foregroundStyle .

Inputs

modifiers
readonly UiModifier[]

ui-rounded-rectanglecomponent@ng-native/expo
import { UiRoundedRectangle } from '@ng-native/expo';and add UiRoundedRectangle to the component's imports

A SwiftUI RoundedRectangle , its corners rounded by cornerRadius .

Inputs

cornerRadius
number

modifiers
readonly UiModifier[]

ui-uneven-rounded-rectanglecomponent@ng-native/expo
import { UiUnevenRoundedRectangle } from '@ng-native/expo';and add UiUnevenRoundedRectangle to the component's imports

A SwiftUI UnevenRoundedRectangle : a rectangle with a radius of its own for each corner.

Inputs

topLeadingRadius
number

topTrailingRadius
number

bottomLeadingRadius
number

bottomTrailingRadius
number

modifiers
readonly UiModifier[]

ui-capsulecomponent@ng-native/expo
import { UiCapsule } from '@ng-native/expo';and add UiCapsule to the component's imports

A SwiftUI Capsule : a rectangle with fully rounded ends.

Inputs

cornerStyle
'continuous' | 'circular'

modifiers
readonly UiModifier[]

ui-circlecomponent@ng-native/expo
import { UiCircle } from '@ng-native/expo';and add UiCircle to the component's imports

A SwiftUI Circle , as large as fits the space it is given.

Inputs

modifiers
readonly UiModifier[]

ui-ellipsecomponent@ng-native/expo
import { UiEllipse } from '@ng-native/expo';and add UiEllipse to the component's imports

A SwiftUI Ellipse , filling the space it is given.

Inputs

modifiers
readonly UiModifier[]

ui-accessory-widget-backgroundcomponent@ng-native/expo
import { UiAccessoryWidgetBackground } from '@ng-native/expo';and add UiAccessoryWidgetBackground to the component's imports

WidgetKit's AccessoryWidgetBackground : the system's backdrop for a lock screen widget, the translucent disc or panel behind its content.

Inputs

modifiers
readonly UiModifier[]

ui-labelcomponent@ng-native/expo
import { UiLabel } from '@ng-native/expo';and add UiLabel to the component's imports

A SwiftUI Label : a title beside an SF Symbol.

Inputs

title
string

systemImage
string

An SF Symbol name.

color
string

The colour of the symbol alone, drawn as the label's icon in that colour, since SwiftUI's label takes no colour. The foregroundStyle modifier colours the title too.

modifiers
readonly UiModifier[]

ui-linkcomponent@ng-native/expo
import { UiLink } from '@ng-native/expo';and add UiLink to the component's imports

A SwiftUI Link : opens destination , a URL, when tapped. It shows label , or the views written inside it. In a widget, a link is how a tap opens the app at a deep link.

Inputs

destinationrequired
string

label
string

modifiers
readonly UiModifier[]

ui-slidercomponent@ng-native/expo
import { UiSlider } from '@ng-native/expo';and add UiSlider to the component's imports

A SwiftUI Slider , or Compose's on Android. valueChanged reports each new value as the thumb moves; note the d, which SwiftUI's event has and @expo/ui 's React prop does not.

Inputs

value
number

min
number

max
number

steps
number

How many steps the range is divided into; none moves the thumb continuously.

modifiers
readonly UiModifier[]

Outputs

valueChanged
UiSliderChangeEvent

ui-steppercomponent@ng-native/expo
import { UiStepper } from '@ng-native/expo';and add UiStepper to the component's imports

A SwiftUI Stepper , between min and max by step .

Inputs

value
number

min
number

max
number

step
number

label
string

modifiers
readonly UiModifier[]

Outputs

valueChange
UiStepperChangeEvent

ui-togglecomponent@ng-native/expo
import { UiToggle } from '@ng-native/expo';and add UiToggle to the component's imports

A SwiftUI Toggle , or Compose's Switch on Android. isOnChange reports each flip. Bound to isOn , it shows what isOn says; without it, it switches itself. label is SwiftUI's alone.

Inputs

isOn
boolean

label
string

modifiers
readonly UiModifier[]

Outputs

isOnChange
UiToggleChangeEvent

ui-text-fieldcomponent@ng-native/expo
import { UiTextField } from '@ng-native/expo';and add UiTextField to the component's imports

A SwiftUI TextField . text is where it keeps what is typed, a nativeState('') : the field writes to it on the UI thread, and textChange reports each change.

Inputs

text
NativeState<string> | null

placeholder
string

modifiers
readonly UiModifier[]

Outputs

textChange
UiTextFieldChangeEvent

ui-color-pickercomponent@ng-native/expo
import { UiColorPicker } from '@ng-native/expo';and add UiColorPicker to the component's imports

A SwiftUI ColorPicker . selection is a colour as hex.

Inputs

selection
string

label
string

supportsOpacity
boolean

modifiers
readonly UiModifier[]

Outputs

selectionChange
UiColorChangeEvent

ui-gaugecomponent@ng-native/expo
import { UiGauge } from '@ng-native/expo';and add UiGauge to the component's imports

A SwiftUI Gauge : value between min and max , drawn as type says, with its labels drawn as text in the slots SwiftUI reads them from, as @expo/ui 's Gauge draws them.

Inputs

value
number

min
number

max
number

type
string

How it is drawn, as the gaugeStyle modifier: 'automatic' , 'circular' , 'circularCapacity' , 'linear' or 'linearCapacity' .

currentValueLabel
string

minimumValueLabel
string

maximumValueLabel
string

modifiers
readonly UiModifier[]

ui-chartcomponent@ng-native/expo
import { UiChart } from '@ng-native/expo';and add UiChart to the component's imports

A Swift Charts chart of data , drawn as type says, iOS only, as Compose has no chart. Each style applies to its own type alone; referenceLines are drawn across it in ruleStyle . showLegend shows only when no point has a colour of its own.

Inputs

datarequired
readonly UiChartDataPoint[]

type
UiChartType

showGrid
boolean

animate
boolean

showLegend
boolean

referenceLines
readonly UiChartDataPoint[]

lineStyle
UiChartLineStyle

pointStyle
UiChartPointStyle

areaStyle
UiChartAreaStyle

barStyle
UiChartBarStyle

pieStyle
UiChartPieStyle

rectangleStyle
UiChartRectangleStyle

ruleStyle
UiChartRuleStyle

modifiers
readonly UiModifier[]

ui-progresscomponent@ng-native/expo
import { UiProgress } from '@ng-native/expo';and add UiProgress to the component's imports

A SwiftUI ProgressView : a bar filled to value , from 0 to 1, or a spinner without one. On Android, Compose's LinearProgressIndicator , a moving bar rather than a spinner without a value. On iOS, timerInterval has the bar keep time by itself from its lower to its upper , emptying unless countsDown is false.

Inputs

value
number

timerInterval
unknown

countsDown
boolean

modifiers
readonly UiModifier[]

ui-formcomponent@ng-native/expo
import { UiForm } from '@ng-native/expo';and add UiForm to the component's imports

A SwiftUI Form : settings-style grouped rows.

Inputs

modifiers
readonly UiModifier[]

ui-sectioncomponent@ng-native/expo
import { UiSection } from '@ng-native/expo';and add UiSection to the component's imports

A SwiftUI Section of a form or a list, under title , iOS only. A <ui-slot name="header"> is its header in place of a title , and a <ui-slot name="footer"> is the note under its rows. When both a non-empty title and a header slot are set, @expo/ui 's SectionView draws the title and drops the slot; an empty title counts as unset. The slot's name is written as it is, not bound, as that is what picks it out of the rows.

Inputs

title
string

modifiers
readonly UiModifier[]

ui-labeled-contentcomponent@ng-native/expo
import { UiLabeledContent } from '@ng-native/expo';and add UiLabeledContent to the component's imports

A SwiftUI LabeledContent : a label, and its content beside it.

Inputs

label
string

modifiers
readonly UiModifier[]

ui-imagecomponent@ng-native/expo
import { UiImage } from '@ng-native/expo';and add UiImage to the component's imports

A SwiftUI Image : an SF Symbol by systemName , a symbol or picture in the asset catalog by assetName , or a picture by uiImage , the URL of a file on the device.

Inputs

systemName
string

assetName
string

uiImage
string

size
number

A symbol's size in points, as a font modifier of that size, which does not scale with Dynamic Type. A font modifier in modifiers wins over it. Without either, the symbol is SwiftUI's default size, where @expo/ui 's own Image and a widget layout draw it at 24.

color
string

A symbol's colour, as a foregroundStyle modifier.

modifiers
readonly UiModifier[]

ui-textcomponent@ng-native/expo
import { UiText } from '@ng-native/expo';and add UiText to the component's imports

A SwiftUI Text , or Compose's on Android. Its text is what is written inside it, <ui-text>Us {{ score }}</ui-text> , as <text> takes its own, or text , which wins over it.

Inputs

text
string

date
unknown

dateStyle
'timer' | 'relative' | 'offset' | 'date' | 'time'

timerInterval
unknown

countsDown
boolean

modifiers
readonly UiModifier[]

ui-date-pickercomponent@ng-native/expo
import { UiDatePicker } from '@ng-native/expo';and add UiDatePicker to the component's imports

A date picker: SwiftUI's DatePicker on iOS, Compose's on Android.

Inputs

title
string

selection
string

displayedComponents
readonly ('date' | 'hourAndMinute')[]

What the picker shows. An empty list shows nothing in SwiftUI; Compose has no such choice, so on Android it shows the date and the time.

range
{ readonly start?: string; readonly end?: string }

accessibilityLabel
string

modifiers
readonly UiModifier[]

disabled
boolean = false

Two-way

value
Date | null = null

The picked date: what [formField] binds.

Outputs

touch
void

The user picked, which is when a form shows a field's errors: a picker has no blur.

dateChange
UiDateChangeEvent

ui-pickercomponent@ng-native/expo
import { UiPicker } from '@ng-native/expo';and add UiPicker to the component's imports

A SwiftUI Picker as a Signal Forms field: <ui-picker [options]="rooms" [formField]="f.room" /> . options draws the choices, each tagged with its value, which is the content slot and tag modifiers @expo/ui otherwise wants written out by hand; children still work for anything richer on iOS. pickerStyle is SwiftUI's ( menu , segmented , wheel , inline ).

Inputs

label
string

systemImage
string

An SF Symbol name.

options
readonly UiPickerOption[]

pickerStyle
'automatic' | 'menu' | 'segmented' | 'wheel' | 'inline' | 'palette'

modifiers
readonly UiModifier[]

disabled
boolean = false

Two-way

value
string | number | null = null

The chosen option's value: what [formField] binds.

Outputs

touch
void

The user picked, which is when a form shows a field's errors: a picker has no blur.

selectionChange
NativeSyntheticEvent<{ readonly selection: string | number }>

ui-bottom-sheetcomponent@ng-native/expo
import { UiBottomSheet } from '@ng-native/expo';and add UiBottomSheet to the component's imports

A sheet that slides up from the bottom of the screen, opened from any component: SwiftUI's on iOS, Material's on Android.

Inputs

fitToContents
boolean = false

The sheet is as tall as its content. Without it the sheet rests at half the screen's height and drags up to all of it. Read as the sheet opens. A sheet with a toolbar on iOS is not sized to its content: it rests at its detents , or at half and full height.

detents
readonly UiPresentationDetent[]

The heights the sheet rests at, iOS only: SwiftUI's presentationDetents . Compose's sheet has a half and a full height and no others, so Android takes no notice of this. On iOS a sheet given detents rests at them rather than at the height of its content, whatever fitToContents says, as @expo/ui 's own sheets do. None, or an empty list, is the sheet without them.

showDragIndicator
boolean = true

Whether the grabber shows at the top of the sheet.

backgroundColor
string

The colour of the sheet behind its content: SwiftUI's presentationBackground on iOS, where a sheet below its full height is otherwise drawn as glass, and Compose's containerColor on Android. Over a background-color the element has from CSS, which colours the sheet the same way. Neither is the sheet as the system draws it.

interactiveDismissDisabled
boolean = false

The user cannot dismiss the sheet; only open closes it. On iOS it is SwiftUI's interactiveDismissDisabled : a swipe down takes the sheet to its smallest detent and no further, and a tap outside it does nothing. On Android a swipe, the back button and a tap outside the sheet are all turned off, as @expo/ui 's own sheet does; Compose's sheet has no way to keep the drag between its heights without the swipe that dismisses it, so that drag is off too.

backgroundInteraction
UiBottomSheetBackgroundInteraction

Whether the screen behind the sheet takes touches, iOS 16.4 and later only: SwiftUI's presentationBackgroundInteraction . 'enabled' always, 'disabled' never, 'automatic' as the system decides, and { upThrough: 'medium' } while the sheet rests at that detent or below it, which should be one of its detents . Unset, SwiftUI decides. Android has no equivalent: Compose's sheet is in a window of its own over the screen, which takes no touch while it shows.

modifiers
readonly UiModifier[]

More SwiftUI modifiers, iOS only, for presentation modifiers such as presentationCornerRadius and for ones that apply to the content, such as ignoreSafeArea . Where the content is in a navigation stack (the build has SwiftUI's toolbar and the sheet is not sized to its content) they are on the ui-zstack inside that stack, since the stack sets up the safe area again for what is inside it, and a content modifier such as background or padding applies below the sheet's bar, where it has one. Otherwise they are after the sheet's own on the ui-group around its content. Android takes no notice of them.

Two-way

open
boolean = false

Whether the sheet is on screen. Written back as false when the user dismisses it.

Outputs

dismissed
void

The sheet has finished closing, whether the user dismissed it or open was set false: the moment another sheet or a dialog can be presented.

Angular components as native iOS and Android views, built and shipped with Expo.

An alpha. MIT licensed. Sponsor its development.

An independent project, not affiliated with or endorsed by Google, the Angular team or Expo. Angular is a trademark of Google LLC.