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 {
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';
<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 importsInstall
npx expo install @expo/ui react-domreact-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 SwiftUIMenu. Its trigger is thelabelinput or a<ui-slot name="label">; its items are children.UiContextMenu- a SwiftUIcontextMenu, the menu a long press opens, iOS only. Its content is threeui-slots:trigger, what is always shown;items, the menu's buttons; andpreview, 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 aui-host:@expo/ui'sRNHostView. It holds one element;matchContentssizes it to that element.UiButton- a SwiftUIButton, as a menu item or on its own.roleis'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'slabel.extraPropsis what the slot tells its parent about itself, such as a swipe group'sedge.UiList- a SwiftUIList.UiSwipeActions- the system's swipe actions on a list row, iOS only. The first child is the row, and each edge's actions areui-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 apressablein aui-view-hostin the row does not press for it.UiVStackandUiHStack- SwiftUI's stacks, withalignmentandspacing, andUiSpacerfor the room left over in one.UiZStack- SwiftUI'sZStack: its children drawn over one another, the first at the back, placed byalignment.UiGroup- SwiftUI'sGroup, iOS only: its children as they are, formodifiersthat belong to several views at once.UiRectangle,UiRoundedRectangle,UiUnevenRoundedRectangle,UiCapsule,UiCircleandUiEllipse- SwiftUI's shapes, iOS only, coloured with theforegroundStylemodifier.UiAccessoryWidgetBackgroundis the system's backdrop for a lock screen widget, for a widget layout: from@expo/ui57.0.22 it has no view in the app, andexpo-widgets57.0.23 or later draws it in the widget.UiLabel- atitlebeside an SF Symbol, iOS only.colorcolours the symbol alone, and theforegroundStylemodifier the title too.UiLinkopens itsdestinationURL when tapped, showing itslabelor the views written inside it, iOS only.UiSlider,UiStepperandUiToggle- withvalueChanged,valueChangeandisOnChangefor what the user did.UiTextField- itstextis anativeState(''), which the field writes to on the UI thread;textChangereports each change.UiColorPicker,UiGaugeandUiProgress. A gauge'stypeis itsgaugeStyle:circular,circularCapacity,linear,linearCapacityorautomatic. ItscurrentValueLabel,minimumValueLabelandmaximumValueLabelare drawn as text where SwiftUI puts each.UiChart- a Swift Charts chart ofdata, iOS only, each point anxlabel or number against ay, with an optionalcolor.typeisline,point,bar,area,pie(iOS 17) orrectangle, and each type takes a style of its own:lineStyle,barStyle,pieStyleand so on.referenceLinesdraws lines across it inruleStyle.UiForm,UiSectionandUiLabeledContent- settings-style grouped rows. A section's header is itstitleor a<ui-slot name="header">, and a<ui-slot name="footer">is the note under its rows. When both atitleand a header slot are set,@expo/ui'sSectionViewdraws thetitleand drops the slot; an emptytitle=""counts as unset.UiImage- an SF Symbol bysystemName, a symbol or picture in the app's asset catalog byassetName, or a picture byuiImage, the URL of a file on the device. A symbol'ssizeis afontmodifier of that many points, which afontmodifier of your own wins over, and itscoloraforegroundStylemodifier.UiText- a SwiftUIText. Its text is what is written inside it,<ui-text>Us {{ score() }}</ui-text>, as<text>takes its own;textsets it too, and wins. Aui-textnested inside one is a span with its own modifiers, drawn after the text: put text that comes after a span in aui-textof its own. On iOS, adatedrawn bydateStyle, or atimerInterval, has it keep time by itself, astimerIntervaldoes aUiProgress: see Time between updates.UiDatePicker- SwiftUI'sDatePickeron iOS, Compose's on Android, and a Signal Forms field: itsvaluemodel is aDateor null, whichever way each platform takes and reports one. A pick is when it emitstouch. 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,selectionstill takes ISO text on iOS.displayedComponentsis 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 optionalstartand an optionalendas ISO text, limits the dates that can be picked on both: fromstarton, up toend, or between the two.accessibilityLabelis what VoiceOver reads for it, and TalkBack with@expo/ui57.0.21 or later.titleis iOS only.UiPicker- a SwiftUIPickeron iOS, Compose's segmented buttons or dropdown menu on Android, and a Signal Forms field.optionsdraws the choices; see Choices:UiPickerbelow.UiBottomSheet- the system's sheet on both platforms, opened by itsopenmodel, with the app's own components as its content. It needs noui-host; see A bottom sheet below.UiNavigationStack,UiToolbarandUiToolbarItem- 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 aplacement. AUiButtonwithrole="close"and no label is the system's own close button, from iOS 26. They need@expo/ui57.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.labelandsystemImage- 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 apickerStylemodifier ahead of any inmodifiers.modifiers- SwiftUI modifiers on the picker. SwiftUI draws a segmented picker as aUISegmentedControl, which afontmodifier does not reach, soUiPickeralso sends eachfontof a segmented picker to the control itself, with itsfamily,sizeandweight. That needs a development build: in Expo Go the segments stay in the system font.disabled- adds SwiftUI'sdisabledmodifier. A form'sdisabled()rule sets it, and so does a baredisabledattribute 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
labelbefore 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)]. WithinteractiveDismissDisablednone 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 ofUiPresentationDetents, 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'spresentationDetents, 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, whateverfitToContentssays. Android takes no notice ofdetents: Compose's sheet has a half and a full height and no others, so there the sheet opens asfitToContentsalone 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'spresentationBackgroundon iOS, where a sheet below its full height is otherwise drawn as glass, and the container colour of Compose's sheet on Android. Abackground-coloron 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 settingopenfalse closes it. On iOS this is SwiftUI'sinteractiveDismissDisabled: 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'spresentationBackgroundInteraction.'enabled'always,'disabled'never,'automatic'as the system decides, and{ upThrough: 'medium' }(anyUiPresentationDetent) while the sheet rests at that detent or below it, which should be one of itsdetents. 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 aspresentationCornerRadius, and modifiers for the content such asignoreSafeArea. Where the build has SwiftUI's toolbar and the sheet is not sized to its content, the content is in aui-navigation-stack, which sets up the safe area again for what is inside it, so the modifiers go on theui-zstackinside the stack. There a content modifier such asbackgroundorpaddingapplies below the sheet's bar, where it has one. Otherwise they go after the sheet's own on theui-grouparound its content. Android takes no notice of them.dismissed- the sheet has finished closing, whether the user dismissed it oropenwas 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/expoimport { 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
matchContentsboolean | { 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.
useViewportSizeMeasurementbooleanui-menucomponent@ng-native/expoimport { 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
labelstringsystemImagestringAn SF Symbol name.
accessibilityLabelstring What VoiceOver reads for the menu's trigger: SwiftUI's accessibilityLabel modifier.
modifiersreadonly UiModifier[]ui-context-menucomponent@ng-native/expoimport { 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
modifiersreadonly UiModifier[]ui-view-hostcomponent@ng-native/expoimport { 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
matchContentsbooleanlayoutRootbooleanFor 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.
modifiersreadonly UiModifier[]Compose's modifiers for the view host, Android only: SwiftUI's view host takes none.
ui-buttoncomponent@ng-native/expoimport { 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
labelstringsystemImagestringAn SF Symbol name.
role'default' | 'cancel' | 'destructive' | 'close'targetstring 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.
modifiersreadonly UiModifier[]Outputs
buttonPressNativeSyntheticEvent<Record<string, never>>ui-dividercomponent@ng-native/expoimport { 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
modifiersreadonly UiModifier[]ui-slotcomponent@ng-native/expoimport { 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
namerequiredstringextraPropsReadonly<Record<string, unknown>>What the slot tells its SwiftUI parent about itself, iOS only.
ui-listcomponent@ng-native/expoimport { 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
modifiersreadonly UiModifier[]ui-swipe-actionscomponent@ng-native/expoimport { 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
modifiersreadonly UiModifier[]ui-vstackcomponent@ng-native/expoimport { 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'spacingnumbermodifiersreadonly UiModifier[]ui-hstackcomponent@ng-native/expoimport { 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'spacingnumbermodifiersreadonly UiModifier[]ui-spacercomponent@ng-native/expoimport { UiSpacer } from '@ng-native/expo';and add UiSpacer to the component's imports A SwiftUI Spacer : the room left over in its stack.
Inputs
modifiersreadonly UiModifier[]ui-zstackcomponent@ng-native/expoimport { 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
alignmentUiZStackAlignmentmodifiersreadonly UiModifier[]ui-groupcomponent@ng-native/expoimport { 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
modifiersreadonly UiModifier[]ui-rectanglecomponent@ng-native/expoimport { 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
modifiersreadonly UiModifier[]ui-rounded-rectanglecomponent@ng-native/expoimport { UiRoundedRectangle } from '@ng-native/expo';and add UiRoundedRectangle to the component's imports A SwiftUI RoundedRectangle , its corners rounded by cornerRadius .
Inputs
cornerRadiusnumbermodifiersreadonly UiModifier[]ui-uneven-rounded-rectanglecomponent@ng-native/expoimport { 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
topLeadingRadiusnumbertopTrailingRadiusnumberbottomLeadingRadiusnumberbottomTrailingRadiusnumbermodifiersreadonly UiModifier[]ui-capsulecomponent@ng-native/expoimport { 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'modifiersreadonly UiModifier[]ui-circlecomponent@ng-native/expoimport { 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
modifiersreadonly UiModifier[]ui-ellipsecomponent@ng-native/expoimport { UiEllipse } from '@ng-native/expo';and add UiEllipse to the component's imports A SwiftUI Ellipse , filling the space it is given.
Inputs
modifiersreadonly UiModifier[]ui-accessory-widget-backgroundcomponent@ng-native/expoimport { 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
modifiersreadonly UiModifier[]ui-labelcomponent@ng-native/expoimport { UiLabel } from '@ng-native/expo';and add UiLabel to the component's imports A SwiftUI Label : a title beside an SF Symbol.
Inputs
titlestringsystemImagestringAn SF Symbol name.
colorstring 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.
modifiersreadonly UiModifier[]ui-linkcomponent@ng-native/expoimport { 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
destinationrequiredstringlabelstringmodifiersreadonly UiModifier[]ui-slidercomponent@ng-native/expoimport { 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
valuenumberminnumbermaxnumberstepsnumberHow many steps the range is divided into; none moves the thumb continuously.
modifiersreadonly UiModifier[]Outputs
valueChangedUiSliderChangeEventui-steppercomponent@ng-native/expoimport { UiStepper } from '@ng-native/expo';and add UiStepper to the component's imports A SwiftUI Stepper , between min and max by step .
Inputs
valuenumberminnumbermaxnumberstepnumberlabelstringmodifiersreadonly UiModifier[]Outputs
valueChangeUiStepperChangeEventui-togglecomponent@ng-native/expoimport { 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
isOnbooleanlabelstringmodifiersreadonly UiModifier[]Outputs
isOnChangeUiToggleChangeEventui-text-fieldcomponent@ng-native/expoimport { 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
textNativeState<string> | nullplaceholderstringmodifiersreadonly UiModifier[]Outputs
textChangeUiTextFieldChangeEventui-color-pickercomponent@ng-native/expoimport { UiColorPicker } from '@ng-native/expo';and add UiColorPicker to the component's imports A SwiftUI ColorPicker . selection is a colour as hex.
Inputs
selectionstringlabelstringsupportsOpacitybooleanmodifiersreadonly UiModifier[]Outputs
selectionChangeUiColorChangeEventui-gaugecomponent@ng-native/expoimport { 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
valuenumberminnumbermaxnumbertypestring How it is drawn, as the gaugeStyle modifier: 'automatic' , 'circular' , 'circularCapacity' , 'linear' or 'linearCapacity' .
currentValueLabelstringminimumValueLabelstringmaximumValueLabelstringmodifiersreadonly UiModifier[]ui-chartcomponent@ng-native/expoimport { 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
datarequiredreadonly UiChartDataPoint[]typeUiChartTypeshowGridbooleananimatebooleanshowLegendbooleanreferenceLinesreadonly UiChartDataPoint[]lineStyleUiChartLineStylepointStyleUiChartPointStyleareaStyleUiChartAreaStylebarStyleUiChartBarStylepieStyleUiChartPieStylerectangleStyleUiChartRectangleStyleruleStyleUiChartRuleStylemodifiersreadonly UiModifier[]ui-progresscomponent@ng-native/expoimport { 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
valuenumbertimerIntervalunknowncountsDownbooleanmodifiersreadonly UiModifier[]ui-formcomponent@ng-native/expoimport { UiForm } from '@ng-native/expo';and add UiForm to the component's imports A SwiftUI Form : settings-style grouped rows.
Inputs
modifiersreadonly UiModifier[]ui-sectioncomponent@ng-native/expoimport { 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
titlestringmodifiersreadonly UiModifier[]ui-labeled-contentcomponent@ng-native/expoimport { UiLabeledContent } from '@ng-native/expo';and add UiLabeledContent to the component's imports A SwiftUI LabeledContent : a label, and its content beside it.
Inputs
labelstringmodifiersreadonly UiModifier[]ui-imagecomponent@ng-native/expoimport { 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
systemNamestringassetNamestringuiImagestringsizenumber 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.
colorstring A symbol's colour, as a foregroundStyle modifier.
modifiersreadonly UiModifier[]ui-textcomponent@ng-native/expoimport { 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
textstringdateunknowndateStyle'timer' | 'relative' | 'offset' | 'date' | 'time'timerIntervalunknowncountsDownbooleanmodifiersreadonly UiModifier[]ui-date-pickercomponent@ng-native/expoimport { 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
titlestringselectionstringdisplayedComponentsreadonly ('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 }accessibilityLabelstringmodifiersreadonly UiModifier[]disabledboolean = falseTwo-way
valueDate | null = null The picked date: what [formField] binds.
Outputs
touchvoidThe user picked, which is when a form shows a field's errors: a picker has no blur.
dateChangeUiDateChangeEventui-pickercomponent@ng-native/expoimport { 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
labelstringsystemImagestringAn SF Symbol name.
optionsreadonly UiPickerOption[]pickerStyle'automatic' | 'menu' | 'segmented' | 'wheel' | 'inline' | 'palette'modifiersreadonly UiModifier[]disabledboolean = falseTwo-way
valuestring | number | null = null The chosen option's value: what [formField] binds.
Outputs
touchvoidThe user picked, which is when a form shows a field's errors: a picker has no blur.
selectionChangeNativeSyntheticEvent<{ readonly selection: string | number }>ui-bottom-sheetcomponent@ng-native/expoimport { UiBottomSheet } from '@ng-native/expo';and add UiBottomSheet to the component's importsA sheet that slides up from the bottom of the screen, opened from any component: SwiftUI's on iOS, Material's on Android.
Inputs
fitToContentsboolean = 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.
detentsreadonly 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.
showDragIndicatorboolean = trueWhether the grabber shows at the top of the sheet.
backgroundColorstring 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.
interactiveDismissDisabledboolean = 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.
backgroundInteractionUiBottomSheetBackgroundInteraction 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.
modifiersreadonly 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
openboolean = falseWhether the sheet is on screen. Written back as false when the user dismisses it.
Outputs
dismissedvoid 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.