Mobile (Expo)

157 native iOS and Android UI modules — primitives, composites, AI surfaces and layout screens — drawn in React Native with spring animation, and hosted through @expo/ui where a real platform view wins

@celestia-project/mobile is the native counterpart to @celestia-project/ui: a component library for Expo apps with 157 modules. A small set of components hosts real platform views — SwiftUI on iOS, Jetpack Compose on Android — through @expo/ui; the rest are drawn in React Native and animated through a centralized motion module — named spring presets over core Animated (the house spring SPRING is damping 18 / mass 1 / stiffness 320) plus procedural loops for spinners, pulses and indeterminate progress. MobileSwitch is now drawn and animated rather than a hosted @expo/ui control.

Looking for the visual tour?

This page is the technical reference — install, peer dependencies, platform support, API. For instructions for running the showcase in Expo Go, see the dedicated Mobile showcase surface.

It is not a port of the web library. The web components assume a DOM, hover states and a CSS box model; the mobile components assume a thumb, a safe area and a touch target floor. What the two packages share is the taxonomy (the same primitive / composite / ai / layout split) and the design tokens, so a component sits in the same mental bucket whichever library you are in.

Routing- and data-agnostic by contract

Every component here is presentational: props in, callbacks out. There is no navigation library, no data fetching, and no auth client. The host app owns routing, data and session state — which is why this package has no dependency on apps/api and can be dropped into any Expo project.

Where it lives

PathWhat it is
packages/mobileThe library (@celestia-project/mobile)
apps/mobileThe Expo showcase app that consumes it

Running the showcase

pnpm mobile starts the Metro dev server for apps/mobile. The root script is turbo dev --filter=mobile, which resolves to the package's dev script (expo start); Turbo auto-detects the expo framework and turbo.json marks dev persistent and uncached, so it stays attached like any other dev server. You pick a target after Metro boots, or ask for one up front:

CommandTargetStatus
pnpm mobileMetro only — press i or a in the terminal—
pnpm --filter mobile iosiOS simulator or device (expo start --ios)Supported
pnpm --filter mobile androidAndroid emulator or device (expo start --android)Supported

Each target needs its own toolchain: Xcode and a booted simulator for iOS, the Android SDK and a running emulator for Android.

@expo/ui is included in Expo Go, so the real SwiftUI and Compose views run in the sandbox client with no native build at all — scan the QR code from pnpm mobile and the showcase opens on a physical device. A development build (npx expo run:ios / npx expo run:android) is only needed once you add native code of your own.

bash
# Typecheck just the librarypnpm --filter @celestia-project/mobile typecheck

Platform support

The library targets iOS and Android. Both platforms get real SwiftUI and Jetpack Compose views through @expo/ui, which is what every component in this package imports. There is no web target — when a screen needs a first-class browser implementation, build it on @celestia-project/ui, which shares this package's taxonomy and design tokens precisely so the same screen can have a native and a browser implementation.

iOSAndroid
Hosted @expo/ui components (MobileHost, MobileList, MobileBottomSheet)✅ SwiftUI✅ Compose
Rest of the package (plain React Native)✅✅
Skia-drawn components (MobileSparkline, MobileChartBar / -Line / -Pie / -Scatter, MobileProgressRing)✅ native Skia✅ native Skia
apps/mobile showcase✅✅

@expo/ui@57 exposes three entry points:

ImportImplementation
@expo/uiUniversal — resolves to the platform implementation (.ios.tsx on iOS, .android.tsx on Android)
@expo/ui/swift-uiiOS only
@expo/ui/jetpack-composeAndroid only

The modules that host native views — MobileHost, MobileList and MobileBottomSheet — import from "@expo/ui" (the universal entry), never from /swift-ui or /jetpack-compose, so the same code gets the right native implementation on both platforms. Everything else in the package is plain React Native and needs no @expo/ui at runtime. One platform branch exists in the package: screen.tsx puts the iOS-only padding keyboard behind a Platform.OS === "ios" check.

Publishing

@celestia-project/mobile is part of the npm publish set: scripts/publish.sh publishes @celestia-project/ui, @celestia-project/create and @celestia-project/mobile. Inside the workspace the package is consumed from source through the workspace protocol ("@celestia-project/mobile": "workspace:*"). Publish it standalone with pnpm publish:mobile, or all packages together with pnpm publish:packages.

Installation

Inside a workspace app the package resolves from the workspace protocol:

bash
pnpm --filter mobile add @celestia-project/mobile@workspace:*

The native dependencies are peer dependencies and must be installed by the app, because they contain platform code that Expo has to autolink:

bash
npx expo install @expo/ui expo-haptics react-native-safe-area-context

Pin react-native to the version Expo SDK 57 pairs with (0.86.3) — npx expo install react-native resolves it. Do not bump to ^0.87 manually: Expo 57's Metro tooling requires rn-get-polyfills, which React Native 0.87 removed.

Peer dependencyRequired byNotes
@expo/uiMobileHost, MobileList, MobileBottomSheetReal SwiftUI / Compose views
expo-hapticsEvery interactive componentSame-frame tactile feedback
react-native-safe-area-contextMobileScreen and everything built on itSafe-area insets
react / react-native—React 19, RN 0.86.3 (Expo SDK 57)

Component categories

Components are grouped by role, not by atomicity — the same rule @celestia-project/ui uses, so the two libraries read the same way.

CategoryDirectoryWhat belongs hereRule of thumb
primitivesrc/components/primitive/Generic building blocks. Each wraps one native control or one plain surface."Would I reach for this in any app?"
compositesrc/components/composite/Opinionated assemblies built from primitives."Is this a <Primitive> with a specific job?"
aisrc/components/ai/Assistant, agent and generative surfaces — transcript, composer, model and context controls, grounding and feedback."Does it only make sense next to a model?"
layoutsrc/components/layout/Full-screen shells and screens. Own the frame; take content through slots."Does it own the whole screen?"
“Primitive” means generic, not atomic

MobileCard ships MobileCardHeader, MobileCardTitle, MobileCardDescription, MobileCardContent and MobileCardFooter — and is still a primitive, exactly as Card is in @celestia-project/ui/primitive. A compound component is not automatically a composite; only an opinionated one is.

primitive — 52 modules

MobileText · MobileLabel · MobileLink · MobileButton · MobileIconButton · MobileFab (floating action button) · MobilePressableScale (press-scale base for custom controls; style is the surface, containerStyle is the wrapper the parent lays out) · MobileTextInput (MobileInput) · MobileTextarea · MobileNumberInput · MobilePasswordInput · MobileOtpInput · MobileSwitch (drawn + spring-animated, not hosted) · MobileCheckbox · MobileRadioGroup · MobileToggleGroup · MobileSlider · MobileStepper (numeric −/+) · MobileRating (star input) · MobileBadge · MobileChip · MobileTag · MobileAvatar · MobileImage · MobileSeparator · MobileDividerText ("or"-style rule) · MobileSpacer · MobileStack / MobileZStack · MobileGrid · MobileAspectRatio · MobileSurface · MobileCard · MobileProgress · MobileProgressRing · MobileSpinner · MobileSkeleton · MobileStatusDot (optionally pulsing presence dot) · MobileSwatch (colour chip with selection ring) · MobilePageDots (paging indicator) · MobileBanner · MobileCallout · MobileTooltip · MobileMenu · MobileModal · MobileAccordion · MobileCollapsible · MobileExpandableText (clamped text with show-more) · MobileCopyableText (tap-to-copy) · MobileToolbar · MobileTabs · MobileList · MobileBottomSheet

composite — 58 modules

Navigation & chrome: MobileNavBar · MobileTabBar · MobileSearchBar · MobileSegmentedControl · MobilePagination · MobileSpeedDial (FAB that springs open into a stack of actions) · MobileWizardStepper · MobileThemeSelector · MobileSocialAuthButtons

Fields & inputs: MobileFormField · MobileSelectField · MobileSheetPicker (bottom-sheet single-choice picker) · MobilePhoneInput · MobilePinPad · MobileFilterChips · MobileChatInput · MobileAttachmentChip

Overlays & feedback: MobileAlert · MobileActionSheet · MobileConfirmDialog · MobileToast / MobileToastProvider / useMobileToast · MobileEmptyState · MobileOfflineBanner · MobileErrorBoundary · MobileInfiniteScrollFooter · MobileCountdownTimer · MobileOtpTimer (resend countdown)

Rows & cards: MobileSettingRow · MobileKeyValueRow · MobileUserRow · MobileTaskRow · MobileFileRow · MobileTransactionRow · MobileUploadProgressRow · MobileNotificationCard · MobileCommentCard · MobileReviewCard · MobileEventCard · MobileMediaCard · MobileProgressCard · MobileStatCard · MobileBalanceCard · MobileProfileHeader · MobileAvatarGroup · MobileFaqItem · MobileTimeline · MobileRatingSummary (star histogram) · MobileMessageBubble · MobileDataTable · MobileKpiRow

Charts: MobileChartScatter · MobileChartBar · MobileChartLine · MobileChartPie · MobileSparkline (dependency-free inline trend)

Skeletons: MobileSkeletonCard · MobileSkeletonList · MobileSkeletonProfile

ai — 27 modules

Conversation: MobileAiMessage (role-aware turn — user bubble, unboxed assistant row, centred system note, tool card) · MobileStreamingText (text with a blinking caret while tokens arrive) · MobileTypingIndicator · MobileAiSkeletonMessage · MobileAiAvatar (idle / thinking / streaming / speaking / error ring) · MobileThinkingBlock (collapsible reasoning trace) · MobileToolCallCard (tool invocation with arguments, result and lifecycle status) · MobileCodeBlock (monospace output, horizontal scroll, maxLines truncation)

Grounding & feedback: MobileCitationChip · MobileSourceList · MobileAiFeedbackBar (copy / regenerate / share / reversible thumbs) · MobileAiCopyButton · MobileAiErrorCard (per-kind copy, retry suppressed where it cannot help) · MobileAiDisclaimer

Composer: MobilePromptInput (attachments, model chip, send ⇄ stop in place) · MobileAttachmentTray · MobileVoiceButton (tap-to-start or push-to-talk) · MobileAudioWaveform · MobileStopButton

Model & context: MobileModelSelector (pill row for the composer, described list for settings) · MobileTokenMeter · MobileSuggestionChips · MobilePromptGallery

Agents & account: MobileAgentCard · MobileAgentTaskRow (status rail) · MobileAiUsageCard · MobileImageResultCard

The AI category is optional by construction

Nothing in primitive, composite or layout imports from ai, and the directory pulls in no new dependency — the mono font the code and tool blocks use is the only platform-level addition. An app with no AI feature can ignore the whole folder, and the barrel export costs it nothing but a name.

layout — 20 modules

MobileScreen · MobileAuthShell · MobileOnboardingScreen · MobileSignInScreen · MobileSignUpScreen · MobileForgotPasswordScreen · MobileResetPasswordScreen · MobileOtpVerifyScreen · MobilePinLockScreen · MobileSettingsScreen / MobileSettingsSection · MobileProfileScreen · MobileNotificationsScreen · MobileChatScreen · MobileListScreen · MobileDetailScreen · MobileMediaGridScreen · MobileDashboardScreen · MobileTabsScreen · MobileWizardScreen · MobileStatusScreen

Import paths

tsx
// Barrel — every categoryimport { MobileButton, MobileHost } from "@celestia-project/mobile"
// One categoryimport { MobileButton } from "@celestia-project/mobile/primitive"import { MobilePromptInput, MobileAiMessage } from "@celestia-project/mobile/ai"
// One componentimport { MobileButton } from "@celestia-project/mobile/primitive/button"
// Infrastructureimport { useMobileTheme } from "@celestia-project/mobile/host"import { darkColors, metrics } from "@celestia-project/mobile/tokens"
// Motion presets and helpers (barrel only, like utils/hooks)import {  SPRING, SPRING_SNAPPY, springTo, springLayoutTo,  pressInTiming, useSpringValue, usePressSpring,} from "@celestia-project/mobile"
// Shared helpers and hooks (barrel only)import {  clamp, chunk, range, truncate, getInitials,  formatCompactNumber, formatRelativeTime, hitSlopFor,  hapticLight, hapticMedium, hapticSelect,  useToggle, useDebounce, useCountdown,} from "@celestia-project/mobile"

The src/utils.ts helpers (clamp, chunk, range, truncate, getInitials, formatCompactNumber, formatRelativeTime, hitSlopFor, hapticLight / hapticMedium / hapticSelect), the src/hooks.ts hooks (useToggle, useDebounce, useCountdown) and the src/motion.ts presets and helpers (see Motion) are exported from the root barrel — they are what the components themselves are built with, offered so host apps can match the library's behaviour.

Setup

MobileHost bridges React Native to the native layer and provides the theme context every component reads.

MobileHost must be given a size

MobileHost renders @expo/ui's Host, whose matchContents and useViewportSizeMeasurement props both default to false. An unsized host lays out to zero height, so every child collapses with it and the app renders as a blank screen — the root view's background, and nothing else. style={{ flex: 1 }} is the fix, and it is not optional.

Reach for useViewportSizeMeasurement instead when the host should size to the available viewport, or matchContents when it should shrink to fit its content.

SafeAreaProvider must sit above it so MobileScreen can resolve insets, and MobileToastProvider must sit inside a sized parent, because its overlay positions itself absolutely against that parent.

tsx
import * as React from "react"import { View } from "react-native"import { SafeAreaProvider } from "react-native-safe-area-context"import { MobileHost, MobileToastProvider } from "@celestia-project/mobile"
export default function App() {  return (    <SafeAreaProvider>      <MobileHost style={{ flex: 1 }}>        <View style={{ flex: 1 }}>          <MobileToastProvider>{/* your navigator / screens */}</MobileToastProvider>        </View>      </MobileHost>    </SafeAreaProvider>  )}

Pass forcedTheme="light" | "dark" to MobileHost to override the device preference — the showcase uses this for its theme toggle.

Using the primitives

tsx
import * as React from "react"import { MobileCard, MobileSwitch, MobileButton, MobileText } from "@celestia-project/mobile"
export function Preferences() {  const [enabled, setEnabled] = React.useState(true)
  return (    <MobileCard>      <MobileSwitch        label="Push notifications"        description="Drawn and spring-animated in React Native"        value={enabled}        onValueChange={setEnabled}      />      <MobileButton onPress={() => console.log("saved")}>Save</MobileButton>    </MobileCard>  )}

Motion

All spring physics lives in one module, src/motion.ts. Before it existed, every component carried its own hand-tuned damping/stiffness pair — nine distinct dampings and eleven distinct stiffnesses across the library, with the house spring retyped in full in a dozen files. Components now import a name that describes the intent of the motion, so the feel of the whole system is tunable in one place:

PresetValuesIntent
SPRING18 / 1 / 320The house spring — state changes: selection, toggles, indicator travel
SPRING_SOFT22 / 1 / 260Layout reveals (height, collapse) where a tight spring reads as a jump-cut
SPRING_SNAPPY14 / 1 / 380Confirmation pops — the check tick, the radio dot, the badge pop — where the overshoot IS the feedback
SPRING_ENTRANCE16 / 1 / 240Arrivals: content enters with a small overshoot and a little energy
SPRING_EXIT22 / 1 / 320Dismissals: overdamped and decisive — nothing re-enters the frame to apologize
SPRING_GAUGE20 / 1 / 120Gauges and progress fills, where overshoot would misreport the value

Three helpers wrap the three animation shapes, with the driver choice made once in motion.ts instead of repeated at every call site (springTo and pressInTiming ride the native driver; springLayoutTo forces the JS driver because layout props cannot ride the native one):

  • springTo(value, toValue, config?) — one-line Animated.spring with a preset.
  • springLayoutTo(value, toValue, config?) — the same for layout props (width, height), which cannot ride the native driver and are forced onto the JS driver.
  • pressInTiming(value, toValue, duration?) — the 90 ms press-in dip. Press-in is deliberately not a spring: the finger is already down, and an overshoot under a held press reads as a wobble. A fixed timing gets the surface to its pressed position before the release spring ever starts.

Two hooks cover the patterns nearly every interactive component used to re-roll. useSpringValue(target, config?) is the declarative chase — an Animated.Value seeded at target (so the first render never springs from zero) that springs after every subsequent change. usePressSpring(rest, pressed, config?) returns a value plus onPressIn/onPressOut handlers to spread on a Pressable; the pair drives whichever property the press should move — buttons and FABs translate 0 → 2 over their hard edge, chips scale 1 → 0.96:

tsx
import * as React from "react"import { Animated, Pressable } from "react-native"import { usePressSpring } from "@celestia-project/mobile"
export function Tile({ onPress }: { onPress?: () => void }) {  // Press feedback in one line: dip to 0.96 on a 90ms timing in,  // spring back to 1 on the house spring out.  const { value: pressAnim, onPressIn, onPressOut } = usePressSpring(1, 0.96)
  return (    <Animated.View style={{ transform: [{ scale: pressAnim }] }}>      <Pressable onPress={onPress} onPressIn={onPressIn} onPressOut={onPressOut}>        {/* ... */}      </Pressable>    </Animated.View>  )}

Two conventions keep the module honest:

  • Components never hand-type a damping/mass/stiffness tuple. If no preset matches the intent, the preset list is wrong — extend motion.ts, not the call site.
  • Raw Animated.timing is reserved for intentional durations: skeleton and status-dot pulses, the indeterminate progress loop, image load fades, the error shake. Those are timed effects, not springs, and stay written as such.

Screens

The layout category is where this package diverges most from the web one. A screen owns the whole frame — safe area, scrolling, keyboard avoidance, header, pinned footer — and takes its content through slots.

The shell + thin-screen split

MobileScreen is the base frame; its body is inset by a default 16pt gutter (the same one the pinned footer uses), which contentContainerStyle merges over — pass paddingHorizontal: 0 for edge-to-edge content. MobileAuthShell adds the logo / heading / form / aside / footer arrangement that all five auth screens share. Each auth screen then supplies only its own fields and defaults:

tsx
import { MobileSignInScreen } from "@celestia-project/mobile"
export default function SignIn() {  const [error, setError] = React.useState<string>()  const [loading, setLoading] = React.useState(false)
  return (    <MobileSignInScreen      error={error}      loading={loading}      socialProviders={[        { id: "google", label: "Google" },        { id: "apple", label: "Apple" },      ]}      onForgotPassword={() => router.push("/forgot-password")}      onSignUp={() => router.push("/sign-up")}      onSubmit={async ({ email, password }) => {        setLoading(true)        setError(undefined)        try {          await auth.signIn(email, password)          router.replace("/")        } catch (cause) {          setError(cause instanceof Error ? cause.message : "Sign-in failed.")        } finally {          setLoading(false)        }      }}    />  )}

The screen owns its field state and reports the result; it never authenticates, stores a session or navigates. Server errors arrive back through error, which is why the same screen works for a credential rejection, a network failure and an offline queue.

Building a new screen

A new screen that resembles an existing one composes the shell — it never re-implements the header. That is what stops six auth screens from drifting apart:

tsx
import * as React from "react"import { MobileAuthShell, MobileButton, MobileFormField, MobileTextInput } from "@celestia-project/mobile"
export function ChangeHandleScreen({ onSave }: { onSave: (handle: string) => void }) {  const [handle, setHandle] = React.useState("")
  return (    <MobileAuthShell      heading="Choose a handle"      subheading="This is how others will find you."      onBack={() => history.back()}    >      <MobileFormField label="Handle" required>        <MobileTextInput          value={handle}          onChangeText={setHandle}          autoCapitalize="none"          autoCorrect={false}        />      </MobileFormField>
      <MobileButton onPress={() => onSave(handle)}>Save</MobileButton>    </MobileAuthShell>  )}

For a screen with no auth framing, compose MobileScreen directly and drive the header through props — it renders a MobileNavBar as soon as you pass title, onBack or either header slot:

tsx
<MobileScreen title="Reports" onBack={goBack} largeTitle>  {/* content */}</MobileScreen>
Onboarding and settings

MobileOnboardingScreen takes a slides array and handles paging, the page indicator and the skip / next / get-started actions. MobileSettingsScreen takes MobileSettingsSection groups and inserts the row separators for you — so a stray divider after the last row is impossible.

Design rules

Every component in this package follows the same non-negotiables. Rules 1–6 are measured — pnpm audit:ui runs scripts/ui-audit/mobile-contrast.mjs, which reads the ramp out of tokens.ts and fails the build on a regression. The rest are held by convention and review. All of them are worth knowing before adding a component:

  1. No hardcoded colours. Everything reads useMobileTheme(); the light and dark ramps are both complete. tokens.ts is the only file in the package where a hex literal belongs — a shadowColor and a scrim both count, and both are tokens now.
  2. Status hues have a luminance ceiling of L_rel <= 0.18333. Each of the four status hues is both a fill carrying white ink (MobileBadge, MobileTag, MobileButton) and ink on a near-white backdrop (MobileAlert title, form errors, MobileProgress fill). Both roles reduce to the same inequality — 1.05 / (L + 0.05) >= 4.5 — so one value per hue serves both and no token family needs splitting. Do not lighten a status hue past the ceiling. The ramp that did failed 19 WCAG AA pairings in light mode while dark passed every one; pnpm audit:ui now asserts all 38.
  3. Derived tokens must be re-derived. destructiveEdge is the sRGB bake of the web token layer's --shadow-destructive-3d (color-mix(in oklch, destructive, black 30%)). Change destructive and the button's 2px bottom edge has to move with it, or the edge stops reading as a thickened border. The gate asserts the relationship numerically rather than trusting two literals that happen to agree.
  4. An escalating gauge needs a perceptible step. MobileTokenMeter walks muted → warning → destructive on a 4pt bar, so adjacent steps must differ in contrast or hue. The luminance ceiling in rule 2 pins both status hues to the same lightness, which is why destructive sits a full ramp step below warning.
  5. Spacing comes from spacing. Five steps — inline 4, label 8, row 12, block 16, section 32 — exported from tokens.ts, and re-exported by the showcase's showcase/spacing.ts so sections keep one import. The component layer predates the export and still carries off-scale literals; pnpm audit:ui prints the census each run so the remaining debt stays visible.
  6. 44×44pt minimum touch target on every interactive element (metrics.minTouchTarget). A control drawn shorter to match the web design system — MobileButton is 32px, MobileSegmentedControl's segments are 36px — meets the floor with hitSlop (hitSlopFor() in src/utils.ts computes it) rather than by inflating the box. Vertical only: horizontal slop bleeds into the adjacent sibling, and React Native resolves the overlap in favour of the last-drawn one.
  7. Haptics fire on the causal commit frame — hapticLight() for normal presses, hapticMedium() for destructive, hapticSelect() for toggles (all in src/utils.ts). Never on mount.
  8. 16px font floor on text inputs, to prevent OS viewport zoom.
  9. Tabular numerals for counters, prices and timers.
  10. Icons arrive as props. The package takes no icon dependency; only structural marks (a tick, a chevron, a magnifier) may be drawn inline. The showcase app is the consumer side of that rule and supplies its own set — see Icons in the showcase.
  11. No hover-only affordances.
  12. Forms never disable the submit button for empty fields. It stays pressable and names what is missing; it is disabled only while loading.
  13. Screens are presentational. Props in, callbacks out — no fetching, no auth client, no routing.
  14. Text children are wrapped; composed children are not. MobileButton, MobileBadge, MobileLink, MobileCardTitle and MobileCardDescription route their children through isTextChildren() (src/utils.ts) to decide whether to wrap them in MobileText. Never replace it with typeof children === "string" or React.isValidElement(children): JSX turns any element with more than one child into an array (+{count}% becomes ["+", count, "%"]), and both of those checks mis-classify that array — the first renders bare strings outside a <Text> and the second nests a <View> inside one. Either way the app crashes at render.
  15. Animation goes through src/motion.ts. Import the preset whose intent matches (SPRING, SPRING_SNAPPY, SPRING_ENTRANCE, …) or the springTo / pressInTiming / usePressSpring helpers — never a hand-typed damping/stiffness tuple. Raw Animated.timing is only for intentional durations (pulses, shakes, fades).

Dependencies

The package adds no runtime dependencies of its own — with one declared exception. Two deliberate consequences:

  • Paging uses ScrollView + pagingEnabled, not reanimated or gesture-handler.
  • Motion uses React Native's built-in Animated behind the centralized motion module (src/motion.ts) — interactive components press, toggle and settle on named spring presets (the house spring SPRING is damping 18 / mass 1 / stiffness 320; see Motion), and spinners, pulses and indeterminate bars run procedural Animated loops. The MobileSlider is built on PanResponder — React Native core dropped Slider, and the community package is a native module that would force every consumer to rebuild.

The chart exception

The chart family is the one group of modules that takes dependencies. MobileChartScatter, MobileChartBar and MobileChartLine render through victory-native on top of Skia, because real curve interpolation, UI-thread gestures and pan/zoom are not things a hand-rolled view tree does well; MobileChartPie draws straight through Skia (victory-native's polar support is its shakiest corner). MobileSparkline is the exception inside the exception: plain Animated, no Skia.

PackageWhy it is needed
victory-nativethe chart primitives — CartesianChart, Scatter
@shopify/react-native-skiathe renderer victory-native draws through
react-native-reanimatedthe press state and animated paths run on the UI thread
react-native-gesture-handlerthe gesture layer underneath reanimated

All four are declared as optional peer dependencies, so the package installs and works without them and nothing else in it pulls them in. Install them only if you render a chart:

bash
npx expo install @shopify/react-native-skia react-native-reanimated react-native-gesture-handlerpnpm add victory-native

Axis tick labels are drawn through Skia rather than React Native, so the Cartesian charts (MobileChartScatter, MobileChartBar, MobileChartLine) need a Skia font from useFont() and take it as the font prop. With no font the data still renders — only the labels are missing. The font is a prop rather than a bundled asset on purpose: shipping a .ttf would force every consumer to carry a font file it may not want, and would stop the charts matching the app's own typeface.

The only other non-obvious dependency is react-native-safe-area-context, which apps/mobile already used before the package needed it.

Icons in the showcase

packages/mobile ships no icon dependency, so apps/mobile picks its own. It uses Heroicons drawn with react-native-svg — the SVG route rather than an icon font.

bash
npx expo install react-native-svgpnpm --filter mobile add react-native-heroicons

Four reasons this route, and not a font:

  • @expo/vector-icons is being retired. Expo now recommends migrating to the @react-native-vector-icons/* font packages, which work in Expo Go, dev builds and every platform. Heroicons has no package in that scope, so the showcase takes the SVG route instead — the same destination, a different mechanism.
  • No native rebuild. react-native-svg is included in Expo Go, so nothing here needs a development build. Use npx expo install so the SDK-compatible version is picked — do not hand-pick one.
  • No font loading at all. Nothing is added to the expo-font config plugin, so the icon-font conflict that the Expo migration guide warns about cannot arise.
  • Per-icon imports. apps/mobile/src/showcase/icons.tsx imports one module per icon, by deep path. The package's barrel (react-native-heroicons/outline) re-exports all 324 icons from a single module and the package declares no exports map, so importing from the barrel would ship the entire set.

Every icon is drawn by one component, ShowcaseIcon, which maps a semantic name ("trash", "search") to a glyph and reads its colour from useMobileTheme(). Nothing outside icons.tsx names a Heroicons component, so the set — or the style, outline versus solid — is swappable in one file.

Heroicons has no brand logos

Tailwind excludes brand marks from the set, so there is no Apple, Google, GitHub or WeChat glyph. The federated sign-in rows are therefore label-only, which is also why MobileSocialProvider.icon stays optional. Real brand marks need a different source — they are not in any general-purpose UI icon set.

Status

Not yet verified on a device

The library is typechecked end to end (package and showcase app) and the showcase Metro-bundles into a working Hermes bundle (npx expo export --platform ios). It has not been run on a simulator or device in this workspace — the machine has no Xcode, so @expo/ui's real native views and gesture behaviour cannot be exercised headlessly.

Three things need a device before this is trusted: the whole package end to end; MobileSlider's gesture geometry in particular — a PanResponder cannot be validated by a typecheck; and the showcase icons, where optical size, stroke weight and vertical alignment inside a 44pt control are all things a typecheck cannot see.