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.
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.
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
| Path | What it is |
|---|---|
packages/mobile | The library (@celestia-project/mobile) |
apps/mobile | The 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:
| Command | Target | Status |
|---|---|---|
pnpm mobile | Metro only — press i or a in the terminal | — |
pnpm --filter mobile ios | iOS simulator or device (expo start --ios) | Supported |
pnpm --filter mobile android | Android 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.
# Typecheck just the librarypnpm --filter @celestia-project/mobile typecheckPlatform 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.
| iOS | Android | |
|---|---|---|
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:
| Import | Implementation |
|---|---|
@expo/ui | Universal — resolves to the platform implementation (.ios.tsx on iOS, .android.tsx on Android) |
@expo/ui/swift-ui | iOS only |
@expo/ui/jetpack-compose | Android 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.
@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:
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:
npx expo install @expo/ui expo-haptics react-native-safe-area-contextPin 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 dependency | Required by | Notes |
|---|---|---|
@expo/ui | MobileHost, MobileList, MobileBottomSheet | Real SwiftUI / Compose views |
expo-haptics | Every interactive component | Same-frame tactile feedback |
react-native-safe-area-context | MobileScreen and everything built on it | Safe-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.
| Category | Directory | What belongs here | Rule of thumb |
|---|---|---|---|
| primitive | src/components/primitive/ | Generic building blocks. Each wraps one native control or one plain surface. | "Would I reach for this in any app?" |
| composite | src/components/composite/ | Opinionated assemblies built from primitives. | "Is this a <Primitive> with a specific job?" |
| ai | src/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?" |
| layout | src/components/layout/ | Full-screen shells and screens. Own the frame; take content through slots. | "Does it own the whole screen?" |
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
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
// 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 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.
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
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:
| Preset | Values | Intent |
|---|---|---|
SPRING | 18 / 1 / 320 | The house spring — state changes: selection, toggles, indicator travel |
SPRING_SOFT | 22 / 1 / 260 | Layout reveals (height, collapse) where a tight spring reads as a jump-cut |
SPRING_SNAPPY | 14 / 1 / 380 | Confirmation pops — the check tick, the radio dot, the badge pop — where the overshoot IS the feedback |
SPRING_ENTRANCE | 16 / 1 / 240 | Arrivals: content enters with a small overshoot and a little energy |
SPRING_EXIT | 22 / 1 / 320 | Dismissals: overdamped and decisive — nothing re-enters the frame to apologize |
SPRING_GAUGE | 20 / 1 / 120 | Gauges 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-lineAnimated.springwith 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:
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/stiffnesstuple. If no preset matches the intent, the preset list is wrong — extendmotion.ts, not the call site. - Raw
Animated.timingis 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:
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:
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:
<MobileScreen title="Reports" onBack={goBack} largeTitle> {/* content */}</MobileScreen>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:
- No hardcoded colours. Everything reads
useMobileTheme(); the light and dark ramps are both complete.tokens.tsis the only file in the package where a hex literal belongs — ashadowColorand a scrim both count, and both are tokens now. - 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 (MobileAlerttitle, form errors,MobileProgressfill). 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:uinow asserts all 38. - Derived tokens must be re-derived.
destructiveEdgeis the sRGB bake of the web token layer's--shadow-destructive-3d(color-mix(in oklch, destructive, black 30%)). Changedestructiveand 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. - An escalating gauge needs a perceptible step.
MobileTokenMeterwalksmuted→warning→destructiveon 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 whydestructivesits a full ramp step belowwarning. - Spacing comes from
spacing. Five steps —inline4,label8,row12,block16,section32 — exported fromtokens.ts, and re-exported by the showcase'sshowcase/spacing.tsso sections keep one import. The component layer predates the export and still carries off-scale literals;pnpm audit:uiprints the census each run so the remaining debt stays visible. - 44×44pt minimum touch target on every interactive element (
metrics.minTouchTarget). A control drawn shorter to match the web design system —MobileButtonis 32px,MobileSegmentedControl's segments are 36px — meets the floor withhitSlop(hitSlopFor()insrc/utils.tscomputes 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. - Haptics fire on the causal commit frame —
hapticLight()for normal presses,hapticMedium()for destructive,hapticSelect()for toggles (all insrc/utils.ts). Never on mount. - 16px font floor on text inputs, to prevent OS viewport zoom.
- Tabular numerals for counters, prices and timers.
- 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.
- No hover-only affordances.
- Forms never disable the submit button for empty fields. It stays pressable and names what is missing; it is disabled only while loading.
- Screens are presentational. Props in, callbacks out — no fetching, no auth client, no routing.
- Text children are wrapped; composed children are not.
MobileButton,MobileBadge,MobileLink,MobileCardTitleandMobileCardDescriptionroute their children throughisTextChildren()(src/utils.ts) to decide whether to wrap them inMobileText. Never replace it withtypeof children === "string"orReact.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. - Animation goes through
src/motion.ts. Import the preset whose intent matches (SPRING,SPRING_SNAPPY,SPRING_ENTRANCE, …) or thespringTo/pressInTiming/usePressSpringhelpers — never a hand-typeddamping/stiffnesstuple. RawAnimated.timingis 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, notreanimatedorgesture-handler. - Motion uses React Native's built-in
Animatedbehind the centralizedmotionmodule (src/motion.ts) — interactive components press, toggle and settle on named spring presets (the house springSPRINGis damping 18 / mass 1 / stiffness 320; see Motion), and spinners, pulses and indeterminate bars run proceduralAnimatedloops. TheMobileSlideris built onPanResponder— React Native core droppedSlider, 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.
| Package | Why it is needed |
|---|---|
victory-native | the chart primitives — CartesianChart, Scatter |
@shopify/react-native-skia | the renderer victory-native draws through |
react-native-reanimated | the press state and animated paths run on the UI thread |
react-native-gesture-handler | the 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:
npx expo install @shopify/react-native-skia react-native-reanimated react-native-gesture-handlerpnpm add victory-nativeAxis 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.
npx expo install react-native-svgpnpm --filter mobile add react-native-heroiconsFour reasons this route, and not a font:
@expo/vector-iconsis 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-svgis included in Expo Go, so nothing here needs a development build. Usenpx expo installso the SDK-compatible version is picked — do not hand-pick one. - No font loading at all. Nothing is added to the
expo-fontconfig plugin, so the icon-font conflict that the Expo migration guide warns about cannot arise. - Per-icon imports.
apps/mobile/src/showcase/icons.tsximports 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 noexportsmap, 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.
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
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.