A lean, production-ready Expo starter with file-based routing, Redux, theming, and a curated set of @rific packages pre-wired.
Live demo: rific.jaydeaton.com, the web build of this app. Each package's demo has its own URL, e.g. rific.jaydeaton.com/demos/toaster. On web, sound starts off, screens are capped at 640px wide, and controls that need the iOS or Android app (OTA updates, the keyboard inset, hitSlop) are turned off with a note. Haptics work in Chrome on Android and are turned off in other browsers. Pinch-to-zoom needs a touch screen.
| Layer | Package |
|---|---|
| Framework | Expo ~58 / React Native ~0.88 |
| Navigation | expo-router ~58 |
| State | Redux Toolkit + redux-persist |
| UI | react-native-paper ~5 |
| Gestures | react-native-gesture-handler ~3.3 |
| Animations | react-native-reanimated ~4.7 |
| OTA | expo-updates ~58 |
| Language | TypeScript ~6 |
The shared foundation: auto-paper, drawer, feedback-press, resizable-input, and scroll-view all require it as a peer dependency. It has no UI of its own. It holds the createSettingsContext/createSettingsSlice/createModuleConfig factories those packages build their settings Providers, Redux slices, and optional-peer injection on (this app's own settingsSlice.ts uses createSettingsSlice too, so a field added to it later is backfilled with its default on rehydrate instead of coming back undefined), plus safeBack, an expo-router back-navigation guard that falls back to / instead of throwing when there's no back-stack to pop (a screen opened by deep link or browser refresh). Configured once in navigation.ts; every screen's back button goes through it. See demos/core.tsx for an isolated, live instance of each factory.
// utils/navigation.ts
import { configureNavigation, safeBack } from '@rific/core';
import { router } from 'expo-router';
configureNavigation({ router });
export { safeBack };
// any screen
<ScrollViewHeader backAction={safeBack} title="Settings" />Adaptive react-native-paper theming. Derives a full triadic Material 3 palette from a single seed color and wires it to system / light / dark appearance automatically. Also carries fixed success/warning/danger semantic color roles (each with on*/*Container variants) alongside MD3's built-in error, and a typed useAutoPaperTheme() hook for reading them with full autocomplete. Bridged to Redux in Theme.tsx for persistence, with useThemeBridgeProps building the Provider's initialValue/onChange/onReady props. expoBlur injects expo-blur, so BlurView (and every blur: true surface built on it) renders real frosted glass; without it, it silently renders its solid fallback. maskedView injects @react-native-masked-view/masked-view, so FadingScrollView fades its content out at an edge while there is more to scroll past it (the auto-paper demo's scrolling dialog); without it, it scrolls with no fade on iOS and Android (web needs nothing).
statusBar={false} turns off auto-paper's own React Native StatusBar: the status bar is styled per screen instead, by the root Stack's statusBarStyle screen option in _layout.tsx, which follows the theme. An app linked against the iOS 27 SDK can't set the status bar app-wide, which is the only thing React Native's StatusBar does on iOS, so per-screen options are the way to style it there. They need UIViewControllerBasedStatusBarAppearance on (set in app.json), and with that key on, a mounted StatusBar logs an error. In a development (dev-client) build on iOS the bar keeps the system's style, since expo-dev-launcher's own view controller sits above react-native-screens' stack; preview and production builds don't have it.
import MaskedView from '@react-native-masked-view/masked-view';
import { Provider, useAutoPaperTheme, useThemeBridgeProps } from '@rific/auto-paper';
import * as ExpoBlur from 'expo-blur';
// settings/onChange come from Redux, onReady marks the splash gate
const bridgeProps = useThemeBridgeProps({ initialValue: settings, onChange, onReady });
<Provider expoBlur={ExpoBlur} maskedView={MaskedView} statusBar={false} {...bridgeProps}>
{children}
</Provider>
// _layout.tsx, inside the Provider
const { dark } = useAutoPaperTheme();
<Stack screenOptions={{ headerShown: false, statusBarStyle: dark ? 'light' : 'dark' }} />
const { colors } = useAutoPaperTheme();
<Text style={{ color: colors.warning }}>Check your connection</Text>Sliding drawer/sheet, spring-animated and theme-aware. Slides in from any of the four edges: left/right for a nav/settings drawer, top/bottom for a sheet, same mechanism either way, so one package covers both use cases (this is what makes a standalone bottom-sheet dependency unnecessary in this stack). One createDrawer() call per instance; combineDrawerProviders flattens nesting multiple drawers into a single wrapper. A panel's size is capped at the window's size, and useDrawerSize() resolves that same geometry for content sized to the panel. See demos/drawer.tsx for a left nav drawer, a right settings drawer, a bottom sheet, and an expandable sheet side by side.
import * as AutoPaper from '@rific/auto-paper';
import { combineDrawerProviders, createDrawer, DrawerProvider } from '@rific/drawer';
const nav = createDrawer({ side: 'left', width: 300 });
const sheet = createDrawer({ side: 'bottom', contentSize: true }); // sizes to its content instead of a fixed height
const settings = createDrawer({ side: 'right', width: 320, blur: true });
const AllDrawersProvider = combineDrawerProviders(
[nav.DrawerInstanceProvider, { content: <NavDrawerContent /> }],
[sheet.DrawerInstanceProvider, { content: <SheetContent /> }],
[settings.DrawerInstanceProvider, { content: <SettingsDrawerContent /> }]
);
// once, near the app root: wires @rific/auto-paper's BlurView into every Drawer with blur: true
<DrawerProvider autoPaper={AutoPaper}>
<AllDrawersProvider>{children}</AllDrawersProvider>
</DrawerProvider>
// anywhere else:
const { open } = sheet.useDrawer();Each panel comes with a swipe-to-dismiss drag handle and an edge-swipe-to-open gesture built in. blur: true renders the panel surface via @rific/auto-paper's BlurView instead of a solid fill, but only once <DrawerProvider autoPaper={AutoPaper}> is mounted above it; without it, blur: true silently falls back to solid. This app mounts it once in Providers.tsx, above every Drawer (the toaster's history sheet opts out with blur={false}, since its content paints its own opaque surface).
Blur-chrome scroll system. Drop-in replacements for ScrollView, FlatList, and SectionList with floating headers/footers, pull-to-search, keyboard awareness, and horizontal paging. Bridged to Redux in Providers.tsx. ScrollViewChromeProvider, mounted above a Tabs navigator, makes an app's own tab bar and each screen's footer hide and reveal with scroll as one piece: see demos/scroll-view-chrome/_layout.tsx.
import { FlatList, ScrollViewHeader, ScrollViewProvider } from '@rific/scroll-view';
<ScrollViewProvider>
<ScrollViewHeader title="My Screen" />
<FlatList data={items} renderItem={renderItem} keyExtractor={keyExtractor} />
</ScrollViewProvider>Haptic and sound feedback wrappers for react-native-paper and built-in pressable components. Drop-in replacements that fire a selection haptic (and, optionally, a sound) on touch-down and notification feedback on long press, covering Button, IconButton, TouchableRipple, Card, Chip, AppbarBackAction, AppbarAction, FAB, Checkbox, Switch, SegmentedButtons, plus the native Pressable/TouchableOpacity/TouchableHighlight. Bridged to Redux in Providers.tsx's FeedbackBridge, so both the haptics and sound toggles persist; the app-wide sound config is useProviderSounds() (a click for selection, a pop for notification). Sound defaults to off in dev builds and on in production. paper={RNPaper} makes the wrappers render as real Paper components; omit it and they render a plain-RN fallback. hitSlop works on its IconButton, AppbarAction and AppbarBackAction, where Paper's own clipping Surface swallows it. See demos/feedback-press.tsx for the Paper wrappers in action plus a sound sampler.
import { Button, FeedbackPressProvider, useFeedbackBridgeProps } from '@rific/feedback-press';
import * as RNPaper from 'react-native-paper';
// once, near the app root
const bridgeProps = useFeedbackBridgeProps({ initialValue: haptic, onChange, soundInitialValue: sound, onSoundChange, sound: soundConfig });
<FeedbackPressProvider paper={RNPaper} {...bridgeProps}>
{children}
</FeedbackPressProvider>
// anywhere else, same props as Paper's own Button
<Button mode="contained" onPress={handlePress}>Submit</Button>Stacking, animated toast notifications with a history stack and swipe-to-dismiss. Includes a provider and a useToast hook. haptics and paper are optional injections: a light haptic tick on the stack controls, and Paper components instead of the plain-RN fallback UI. This app also swaps the history view's default Modal for a @rific/drawer bottom sheet (HistoryDrawerContainer in Providers.tsx), and renders a Portal.Host just inside ToastProvider so dialogs and menus (which render through a portal) can call useToast() too. Its ThemedToaster draws each toast on auto-paper's BlurView (a custom Card), so toasts frost like Dialog and Menu while the theme's blur is on, and sets labelStyle/captionStyle from the theme's bodyMedium/bodySmall, so a fontFamily set on auto-paper's Provider reaches toasts too.
import { BlurView, useAutoPaperTheme, useBlur } from '@rific/auto-paper';
import { type ToastCardProps, Toaster, ToastProvider, useToast } from '@rific/toaster';
import * as Haptics from 'expo-haptics';
import * as RNPaper from 'react-native-paper';
const ToastCard = ({ children, style }: ToastCardProps) => (
<BlurView blur={useBlur()} elevation={1} style={[style, { overflow: 'hidden' }]}>{children}</BlurView>
);
const ThemedToaster = () => {
const { fonts } = useAutoPaperTheme();
return <Toaster Card={ToastCard} captionStyle={fonts.bodySmall} labelStyle={fonts.bodyMedium} />;
};
// Wrap your app
<ToastProvider haptics={Haptics} paper={RNPaper}>
{children}
<ThemedToaster />
</ToastProvider>
// Trigger anywhere
const { success } = useToast();
success('Saved!');OTA update hook for Expo apps. Checks for updates on launch and on every foreground resume, prompting to restart as soon as one's found: pass autoPrompt: false to stage them silently instead. Also exposes a manual check(), plus onConfirm/onInfo/onError callbacks to replace the default Alert.alert dialogs. This app calls it from a small UpdateChecker inside ToastProvider in Providers.tsx, so onError (in practice a failed confirm/reload during the auto-prompt, since UpdateChecker never calls check()) shows an error toast instead of an Alert. Failed background checks stay silent.
import { useUpdateErrorToast } from '@rific/toaster';
import { useUpdater } from '@rific/updater';
// background polling is automatic; rendered inside ToastProvider, since useUpdateErrorToast needs its context
const UpdateChecker = () => {
const onError = useUpdateErrorToast();
useUpdater({ onError });
return null;
};
<ToastProvider>
<UpdateChecker />
{children}
</ToastProvider>Auto-advancing focus chain for form inputs. Call the hook once, then register() once per input in order: it returns { ref, props }, so pass ref to the input's ref and spread props onto it, and pressing Next or Return moves focus to the next field automatically, with no index or ref bookkeeping.
import { useFocusChain } from '@rific/focus-chain';
const register = useFocusChain();
const first = register();
const second = register();
<TextInput ref={first.ref} {...first.props} returnKeyType="next" />
<TextInput ref={second.ref} {...second.props} returnKeyType="done" onSubmitEditing={handleSubmit} />Auto-growing, drag-resizable multiline text input. Grows with its content as the user types, and a drag handle lets them resize it manually between minHeight and maxHeight. Renders React Native's own TextInput by default; pass TextInputComponent per instance, or mount ResizableInputProvider once, to swap in react-native-paper's instead. See demos/resizable-input.tsx, which mounts the provider around its own screen (harmless here, since no other screen uses ResizableInput; the setting it applies is app-wide either way).
import { ResizableInput, ResizableInputProvider } from '@rific/resizable-input';
import { TextInput as PaperTextInput } from 'react-native-paper';
// once, near the app root: sets the app-wide default (module-level config, not context), so every ResizableInput uses Paper's TextInput
<ResizableInputProvider TextInputComponent={PaperTextInput}>
{children}
</ResizableInputProvider>
const [notes, setNotes] = useState<string | null>(null); // onChangeText reports null when empty
<ResizableInput value={notes} onChangeText={setNotes} minHeight={80} maxHeight={300} />Shared gestures, so every swipe and pinch feels the same. SwipeToDismiss is content swiped or flicked away sideways (and with edge, off the top or bottom edge too): it follows the finger, fades, springs back when let go short, and calls onDismiss once it has flown off. Without an edge a drag that starts up or down is left to the scroll view around it, so it fits swipe-to-delete rows, and its ref's restore() brings content back when the app declines the dismissal (a confirm cancelled). PinchZoomView zooms its content about the point between the fingers and pans it, clamped so the view is never left uncovered; usePinchZoom is the same zoom as a gesture and shared values, for content that applies the transform itself (a Skia canvas). There's nothing to wire: it only needs the GestureHandlerRootView already at the root of Providers.tsx. See demos/gestures.tsx for rows swiped away in a scroll view (one asks first and comes back on Cancel), a banner swiped off the bottom edge, and a photo to pinch. On the web a swipe works with a mouse too, but a pinch needs a touch screen.
import { PinchZoomView, SwipeToDismiss, type SwipeToDismissHandle } from '@rific/gestures'
// a row in a ScrollView or FlatList; keep a delete button too, since a screen reader can't swipe
<SwipeToDismiss onDismiss={() => remove(item.id)}>
<Row item={item} />
</SwipeToDismiss>
// asking first: the row flies off, and comes back on Cancel
const swipe = useRef<SwipeToDismissHandle>(null)
<SwipeToDismiss ref={swipe} onDismiss={() => setConfirming(true)}>
<Row item={item} />
</SwipeToDismiss>
swipe.current?.restore()
// a banner at the bottom of the screen, also pushed down off it
<SwipeToDismiss edge='bottom' onDismiss={hide}>
<Banner />
</SwipeToDismiss>
<PinchZoomView style={{ height: 240 }}>
<Image source={{ uri }} style={{ height: '100%', width: '100%' }} />
</PinchZoomView>Names every async condition this app's first screen depends on and holds the splash screen up until all of them report ready, instead of hiding it the moment the first one resolves and letting anything else (an icon font, a hydrated preference) pop in a beat later. Wired into Theme.tsx: theme (auto-paper's own Provider onReady) and fonts (the icon font every react-native-paper icon in this app depends on, preloaded via expo-font's useFonts). See demos/splash-gate.tsx for an isolated, replayable simulation of the mechanism, plus a live comparison demoing the Gate component below. The real splash screen can only ever show once, at cold launch, so it can't be demoed directly from a screen you navigate to.
import { createGate } from '@rific/splash-gate';
// utils/splashGate.ts, created once, at module scope
export const { markReady: markSplashReady, useReady: useSplashReady, pendingGates: pendingSplashGates, Gate: SplashGate } = createGate(['theme', 'fonts'] as const);
// Theme.tsx
const [fontsLoaded, fontError] = useFonts(MaterialCommunityIcons.font);
useSplashReady('fonts', fontsLoaded || fontError != null); // a failed load still lifts the splash
const onReady = useCallback(() => markSplashReady('theme'), []); // a one-shot callback, not a boolean, call directlyGate (exported here as SplashGate, not used yet) is useReady plus render-gating in one step: it withholds children until ready is true, instead of just marking the gate. Reach for it over useReady when what you're mounting is a component whose own state locks in on first render (a lazy useState(() => initialProp) initializer, the same pattern most context providers use): marking the gate ready doesn't help if the child already locked onto a stale placeholder before that:
// keyboardLayout: null while loading from storage, the real value once it resolves
<Gate gate="keyboardLayout" ready={layout !== null}>
<ThirdPartyLayoutProvider initialLayout={layout}>{children}</ThirdPartyLayoutProvider>
</Gate>These are not included by default but are built to work seamlessly with this stack. Each has an info screen under src/app/packages/ with its install command and usage.
| Package | Description |
|---|---|
@rific/heatmap |
GitHub-style activity heatmap with SVG rendering and customizable cell modes |
@rific/scanner |
Full-screen barcode scanner with animated overlays, pinch zoom, timeout ring, and scan tracking |
@rific/timer |
Animated SVG progress ring timer |
expo-dev-client is installed, so the dev server opens a development build of this app, not Expo Go. Build and install one first, and again after any native change:
npm install
npm run ios # expo run:ios: build + install a development build on the iOS simulator, then start the dev server
npm run ios:device # same, on a connected physical iOS device
npm run android # same, on an Android emulatorios/ and android/ are gitignored, generated from app.json: npm run ios/npm run android only generate them when they're missing, so after changing app.json's native config or adding a native module, regenerate them with npx expo prebuild --clean first.
After that, day to day:
npm start # dev server; opens the installed development build
npm run client # same, with --dev-client passed explicitly
npm run reset # same as start, with a cleared Metro cachenpm run verify # lint + test + typecheck
npm run lint
npm run fix # lint with --fix
npm test
npm run typecheck
npm run doctor # expo install --fix + expo-doctorbuild and update are plain eas build and eas update, with the build profiles (development, preview, production) in eas.json. Nothing in them runs npm run verify, so run it first:
npm run build -- --profile preview
npm run build -- --profile production
npm run update -- --branch production --environment production --message "..."
npm run submitBefore an OTA update, bump otaVersion in release.ts (the version label shown in the updater demo) and commit it.
The web build is a single-page app exported with npx expo export -p web into dist/ and hosted on Vercel. vercel.json turns off Vercel's own Git deploys and sends every path to index.html, so a link straight to a demo route works. deploy.yml deploys on a v* tag: it runs the tests, then vercel build and vercel deploy --prebuilt --prod, using the VERCEL_TOKEN, VERCEL_ORG_ID and VERCEL_PROJECT_ID repo secrets.
git tag v1.0.0 && git push origin v1.0.0npx expo serve previews dist/ locally, but it has no index.html fallback, so only links from inside the app work there.
src/
app/ # expo-router file-based routes (+not-found.tsx for unmatched URLs)
demos/ # example screens for each included @rific package
packages/ # example screens for each optional @rific package
components/ # shared UI components (Providers, Theme, WebNote, a demo screen shared by two routes)
constants/ # app-wide constants (incl. release.ts's otaVersion)
hooks/ # custom hooks, incl. sounds/ packs for @rific/feedback-press
redux/ # store, slices, persistor
types/ # shared TypeScript types
utils/ # splash gate, navigation (safeBack), web haptics check, web width cap, and other helpers
__tests__/ # Jest test suite
__mocks__/ # Jest mocks for native/Expo modules, picked up automatically
- App identity:
name,slug, andschemein app.json.schemeis the deep-link scheme: make it unique per app (e.g. derived from the slug), since the starter's genericappcollides with any other app claiming it - Bundle identifiers:
ios.bundleIdentifierandandroid.packagein app.json - EAS project:
extra.eas.projectIdandupdates.url(https://u.expo.dev/<projectId>) in app.json both point at this starter's EAS project. Delete both, then runeas init(creates or links your own project and writesextra.eas.projectId) andeas update:configure(writes the matchingupdates.url) - Web deploy:
.vercel/is gitignored, so a new app starts unlinked. Create its own Vercel project (vercel link), then set the threeVERCEL_*secrets on its repo; the org and project IDs are in.vercel/project.json. Delete deploy.yml and vercel.json if the app has no web build - Theme seed color: defaults to
#6750a4; override by swappingthemeReducerforcreateThemeReducer({ color: '...' })from@rific/auto-paperin redux/store.ts
MIT, see LICENSE. It's a template, so start your own projects from it freely.