agentsclimarketplace

Expo ui migration

Skill LiamMorrow/LiftLog/.claude/skills/expo-ui-migration

Use when moving a react-native-paper component to native expo-ui (SwiftUI on iOS / Jetpack Compose on Android), or authoring any new platform-split native control - i.e. creating a `foo.tsx` + `foo.android.tsx` + `foo-props.ts` triple that renders `@expo/ui` `Host`s. Covers the seedColor rule, the icon-interop trap, and the Compose/SwiftUI gotchas that are easy to get wrong.From its SKILL.md

Install
npx -y skills add LiamMorrow/LiftLog --skill expo-ui-migration

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

SKILL.md

4.7 KB, as published. Nobody here has run it

Migrating a component to expo-ui (native)

LiftLog is incrementally moving off react-native-paper onto native controls via @expo/ui - SwiftUI on iOS, Jetpack Compose (M3 expressive) on Android. Each migrated control is a platform-split triple.

The triple

For a control foo, in its own folder under the relevant components/presentation/.../foo/:

  • foo-props.ts - the shared props interface + any shared enums. Both platform files import from here. Mark platform-only props (icon is Android XML drawable; systemImage is an iOS SF Symbol).
  • foo.tsx - the iOS implementation, using @expo/ui/swift-ui (Host, Button, …) and @expo/ui/swift-ui/modifiers.
  • foo.android.tsx - the Android implementation, using @expo/ui/jetpack-compose.

Metro picks .android.tsx on Android and .tsx elsewhere. Reference exemplar: components/presentation/foundation/native-button/ (props + both platforms, clean and current).

Theming: seedColor is (almost) the only input

  • On every Host, pass seedColor={colors.seedColor} from useAppTheme() - never colors.primary. seedColor is the real source colour that seeds the whole M3/SwiftUI palette; colors.primary is already derived from it, so re-seeding from it produces the wrong ramp. seedColor is undefined for the default scheme, which correctly lets Compose fall back to Material You / the wallpaper palette.
  • Let native components own their slot colours. Don't pass a colors={{ containerColor, contentColor, checked… }} object to a native control, and don't tint/foregroundStyle an Icon that sits inside a native button/toggle - the component already colours its container/content/checked slots from the seeded palette, and an Icon child inherits the content colour. Hand-colouring only fights the palette.
  • Do pick the right variant (e.g. HorizontalFloatingToolbar variant="standard" vs "vibrant"), since that changes which ramp a slot sources from.
  • tint / foregroundStyle is warranted only for a bare Icon/label not inside a colour-providing native control (e.g. an Icon in a Row), or on iOS where a borderless/plain button drops its label tint - reach for it last.

Icons inside Compose must be native

Import an XML vector drawable per icon from @expo/material-symbols and render it with expo-ui's own Icon - not the RN Paper / @material-symbols-react-native wrapper:

import { Icon } from "@expo/ui/jetpack-compose";
import AddIcon from "@expo/material-symbols/add.xml";
<Icon source={AddIcon} size={24} />;

An RN Icon hosted inside a Compose button does not survive interop and fails differently per component (mangled FAB with the icon stranded; or the icon silently renders to nothing while the label shows) - it looks like a styling bug but it's interop; manual sizing won't fix it. No Metro config needed: xml is in Expo's default assetExts and @expo/ui/jetpack-compose registers its own asset transformer (don't import that subpath yourself - it isn't in the package exports).

Compose gotchas

  • A Host needs a definite size if any child uses the weight modifier - matchContents measures against content, so weight(1) has no bounded width to divide and silently collapses to zero.
  • Shape.Pill({}) must be called, not used as JSX (<Shape.Pill /> loses the brand and fails typecheck). Row's horizontalArrangement takes { spacedBy: n }, not a string.
  • Compose Text is invisible to Maestro's view hierarchy - a Compose button can't be tapped by label; tap by point. Expo-ui switches on Android carry no testID.

iOS (SwiftUI) notes

  • buttonStyle('borderedProminent' | 'bordered' | 'borderless') maps to filled/tonal/text; 'plain' drops the tint along with the chrome, leaving a label that doesn't read as a button.
  • buttonStyle('glass' | 'glassProminent') and glassEffect(...) are iOS 26+.

Native rebuild caveat

New native deps / config-plugin changes need a native rebuild - don't run expo run:* yourself; describe the change and ask Liam to rebuild, then verify. JS-only edits to these files hot-reload.

Verify

From app/: npm run typecheck and npm run lint (the react-compiler rule runs via eslint). Then look at both platforms on device.

Keep looking

Skills are one crate of 627,801. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.