Native rendering

Hand a component to the platform's own UI toolkit with the native prop.

Five components can render the platform's real control instead of PanelUI's: Button, Switch, Slider, Select and BottomSheet. Pass native and rendering goes to SwiftUI on iOS and Jetpack Compose on Android.

<Switch native value={enabled} onValueChange={setEnabled} label="Notifications" />

Why only these five

These are the controls where a faithful re-implementation is not good enough. A switch, a slider, a picker, a sheet and a button are muscle memory — users notice when the animation curve, the spring, the haptic or the dismiss gesture is a near-miss, even when they cannot say why. Everything else in the library is better served by tokens and Reanimated, where matching the platform is not the goal.

Installation

@expo/ui is an optional peer dependency. Nothing needs it until you write native.

npx expo install @expo/ui

Without the package installed, native is a silent no-op — the styled component renders as usual. Nothing throws, nothing warns. The same applies on web, which has neither SwiftUI nor Compose.

Check at runtime if you need to branch on it:

import { hasNativeUI } from 'panelui-native';

if (hasNativeUI()) {
  // the platform control is available
}

Theme tokens do not apply

This is the part that surprises people, so it is worth stating plainly: in native mode the platform draws the control. It uses its own colours, metrics, typography and press behaviour. Your theme does not reach it, className is ignored, and most of the component's own props go with it.

That is the trade, not a bug. If you want the control to match your design system, do not pass native.

What survives per component

Button

<Button native variant="outline" onPress={save}>
  Save changes
</Button>
PropIn native mode
children (string)Becomes the button label
onPressPassed through
disabledPassed through
variantMapped: primary/secondary/destructive → filled, outline/social → outlined, ghost → text
sizeSets the height
fullWidth, classNameIgnored — see sizing below
loading, startContent, endContentIgnored

loading is the one to watch — a native button has no spinner state, so a loading button looks identical to an idle one. Disable it yourself, or do not use native on buttons that load.

A native button fills the width it is given. Put two in a flex-row with flex-1 on each to share a line, or in a narrower parent to shrink them — width is ordinary layout rather than something the button decides.

Switch

<Switch native value={enabled} onValueChange={setEnabled} label="Wi-Fi" />
PropIn native mode
value, onValueChange, disabledPassed through
labelText drawn beside the control — native only, the styled switch has no label
size, classNameIgnored

Slider

<Slider native label="Brightness" showValue value={level} onValueChange={setLevel} />
PropIn native mode
value / onValueChangePassed through
min, max, stepPassed through
disabledPassed through
label, showValue, formatValueStill yours — the caption row above the track is not part of the platform control
color, size, the slot classNamesIgnored
onValueCommitNot fired — the platform reports changes, not gesture ends

Select

<Select native value={fruit} onValueChange={setFruit}>
  <Select.Item value="apple" label="Apple" />
  <Select.Item value="banana" label="Banana" />
</Select>
PropIn native mode
value, onValueChange, disabledPassed through
Select.Item childrenDeclare the options, same as always
nativeAppearancemenu (default) is a dropdown; wheel is an inline rotor, iOS only
placeholder, title, classNameIgnored

placeholder has nowhere to go: a native picker always has a selection, so an unset value shows the first option rather than placeholder text. Set an initial value, or add an explicit "None" item.

BottomSheet

<BottomSheet native snapPoints={['half', 'full']} open={open} onOpenChange={setOpen}>
  <BottomSheet.Content>
    <Text>Anything you like in here.</Text>
  </BottomSheet.Content>
</BottomSheet>
PropIn native mode
open / onOpenChange / defaultOpenPassed through
snapPointsDetents the sheet rests at — native only
dismissiblePassed through
BottomSheet.Content classNameIgnored — the platform draws the container

The content inside the sheet is still yours and still themed. Only the chrome — the container, the corner radius, the grabber, the backdrop, the dismiss gesture — belongs to the platform.

snapPoints takes 'half', 'full', { fraction } or { height }. The last two are iOS-only; Android snaps them to the nearest of half and full.

Notes

Every native control is given an explicit height, and takes its width from ordinary layout. None of them ask the platform to size the host for them.

That is not a stylistic choice. The platform measures its own content a frame late, and again whenever that content changes — so a host left to work its own size out renders at nothing on the first frame, collapsing against whatever sits above it, and jumps into place on the first press. Asking for one axis only does not avoid it. The answer is not to ask.

The same reasoning applies inside the native sheet: its content is given a floor matching the first detent, because a hosted box shorter than the sheet gets centred in it, which is why short sheet content used to float in the middle instead of starting at the top.

The native sheet stays mounted and toggles its presentation, where the styled sheet unmounts on close. If you rely on the content unmounting to reset state, key it or reset it yourself.

@expo/ui is versioned with the Expo SDK and needs SDK 56 or newer for these components. On SDK 56+ it runs in Expo Go; on older SDKs you need a development build.

On this page