ReadyNative
UI
Onboarding

Make it yours

Give the app your name, bundle id, colours (light and dark), icon, splash, font and onboarding copy - and check each one landed.

Hey - this is the first thing I'd do with a fresh tree. By the end the app shows your name, your colours in light and dark mode, your icon and splash, your font and your onboarding copy, and bun run doctor stops warning about placeholders.

Before you start

  • Tier: any. The Free tier has no gen:* scripts and no doctor, so there you edit the generated files by hand (each step says which).
  • Time: about 30 minutes, plus a native rebuild at the end.
  • Runs in: Expo Go for colours, font and onboarding. The name, icon and splash are native, so you only see them in a dev build or a store build - Expo Go always shows its own. See Expo Go or a dev build.
  • Accounts: none.
  • Previous tutorial: Quickstart - the app runs and setup is done.

Almost everything lives in one file, readynative.config.ts at the repo root. The full field list is in Config; this page covers the branding fields in the order I'd do them.

1. Set the name and ids

Open readynative.config.ts and change the app block. Here's the whole file with example values - keep your own urls, store, links, features and privacy if you already set them. On the Free tier the file has no features line; don't add one.

readynative.config.ts
import type { ReadyNativeConfig } from "./src/lib/readynative-config";

export default {
  app: {
    name: "Trailmix",
    slug: "trailmix",
    scheme: "trailmix",
    owner: "",
    easProjectId: "",
    ios: { bundleId: "com.yourcompany.trailmix" },
    android: { package: "com.yourcompany.trailmix" },
  },
  brand: { primary: "#0f766e", accent: "#f59e0b", radius: 12, font: "Inter" },
  urls: { website: "", support: "mailto:", privacy: "", terms: "" },
  store: { appStoreId: "", playPackage: "" },
  links: { domain: "", appleTeamId: "", androidCertFingerprints: [] },
  features: { reviewPrompt: true, updatesBanner: true, tracking: false },
  privacy: { askEverywhere: false },
} satisfies ReadyNativeConfig;

name is the label under the icon, scheme is the deep-link prefix (trailmix://), and the two ids are what the App Store and Google Play know your app by. Pick the ids before the first store build: you can't change them on a published app. Dev and preview builds append .dev / .preview to both ids and (Dev) / (Preview) to the name, so all three install side by side.

bun run doctor

You should see: no more warnings for app.name or the com.acme.app ids. The Home tab's title already reads your name; the label under the icon changes with the next native build (step 5).

2. Pick your brand colours

brand in the same file drives the palette: primary becomes the light-mode primary token, accent the brandAccent token, and radius the base corner radius (radius.lg; the other sizes derive from it). They flow through src/theme/tokens.ts. Regenerate the files your UI stack reads:

bun run gen:theme

That rewrites src/global.css + tailwind.config.js (NativeWind 4), src/global.css (NativeWind 5) or tamagui.config.ts (Tamagui); StyleSheet and Unistyles read tokens.ts directly. On the Free tier, which has no gen:theme, also change the --primary HSL triple (for example --primary: 221 83% 53%;) in the :root block of src/global.css by hand. Then restart with a clean cache so Metro picks up the new theme:

bun run start -c

You should see: primary buttons and the active tab icon in your primary colour.

3. Set the dark-mode colours

In dark mode primary is a neutral light grey, not your brand colour - a dark brand colour on a near-black background is hard to read. To keep your brand in dark mode, pick a lighter shade of it and set it in the dark palette of src/theme/tokens.ts:

src/theme/tokens.ts
  dark: {
    // ...the other tokens stay as they are
    primary: "#5eead4",
    primaryForeground: "#042f2e",
    // ...
  },

primaryForeground is the text on primary buttons, so keep the pair readable. Then regenerate:

bun run gen:theme

You should see: in Settings → Appearance → Dark, primary buttons and the active tab use your dark shade.

4. Load your font

brand.font is only a family name, exported as fontFamily from src/theme/tokens.ts. Nothing loads the font file for you, so until you do, the app renders the system font.

Put the file in assets/fonts/, then load it in src/app/_layout.tsx and keep the splash up until it's ready. The key you pass to useFonts must equal brand.font. Here's the template's root layout with the font added:

src/app/_layout.tsx
import { useFonts } from "expo-font";
import { Stack, usePathname } from "expo-router";
import * as SplashScreen from "expo-splash-screen";
import { useEffect } from "react";

import { AnimatedSplashOverlay } from "@/components/animated-icon";
import { UpdatesBanner } from "@/components/updates-banner";
import { useAuthRedirect } from "@/hooks/use-auth-redirect";
import { useOnboardingRedirect } from "@/hooks/use-onboarding-redirect";
import { useSignOutCleanup } from "@/hooks/use-sign-out-cleanup";
import { analytics } from "@/lib/analytics";
import { Providers } from "@/providers";
import { hydrateThemeMode } from "@/stores/theme-mode";

export { ErrorBoundary } from "@/components/error-boundary";

SplashScreen.preventAutoHideAsync();

export default function RootLayout() {
  const [fontsLoaded, fontError] = useFonts({
    Inter: require("../../assets/fonts/Inter-Regular.ttf"),
  });
  const ready = fontsLoaded || fontError !== null;

  useEffect(() => {
    if (!ready) return;
    // Single hydration point: the theme provider only reads the store.
    hydrateThemeMode().finally(() => {
      SplashScreen.hideAsync().catch((err: unknown) => {
        console.warn("[splash] hideAsync failed", err);
      });
    });
  }, [ready]);

  if (!ready) return null;

  return (
    <Providers>
      <AnimatedSplashOverlay />
      <UpdatesBanner />
      <RootStack />
    </Providers>
  );
}

/** Inside `Providers` so redirect hooks can read stores/providers; routes come from `src/app`. */
function RootStack() {
  useOnboardingRedirect();
  useAuthRedirect();
  useSignOutCleanup();

  const pathname = usePathname();
  useEffect(() => {
    analytics.screen(pathname);
  }, [pathname]);

  return (
    <Stack screenOptions={{ headerShown: false }}>
      <Stack.Screen name="(tabs)" />
      <Stack.Screen name="examples" />
      <Stack.Screen name="+not-found" />
      {/* gen:stack */}
    </Stack>
  );
}

Your root layout may differ slightly - the Free tier has no UpdatesBanner and no {/* gen:stack */} marker, and screens you generated with gen screen --modal add <Stack.Screen> lines. Keep those; only the useFonts lines, the ready guard and the [ready] dependency are new. expo-font is already a dependency, and useFonts works in Expo Go.

Your kit's Text doesn't read fontFamily, so pass it in src/components/ui/text.tsx.

Add fontFamily to the tokens import, and put it first in the style array so a style prop still wins:

src/components/ui/text.tsx
import { fontFamily, fontWeight } from "@/theme/tokens";

// ...inside Text, the style prop of <RnrText>:
      style={[
        { fontFamily },
        color ? { color: colors[color] } : null,
        weight ? { fontWeight: fontWeight[weight] } : null,
        align ? { textAlign: align } : null,
        style,
      ]}

Bold weights need their own file and their own family name, for example "Inter-SemiBold".

You should see: every screen in your font after a reload. If text still looks like the system font, the useFonts key doesn't match brand.font.

5. Replace the icon and splash

Drop a square PNG, at least 1024 px, at assets/brand/icon.png, then:

bun run gen:assets

It writes assets/images/icon.png, the three Android adaptive icon layers (android-icon-foreground/background/monochrome.png), splash-icon.png and favicon.png, on a brand.primary background. Pass --source path/to/icon.png to read from another file. It runs under Node because sharp hangs inside bun. On the Free tier, replace those files in assets/images/ by hand.

The splash is the expo-splash-screen plugin in app.config.ts: splash-icon.png, 76 pt wide, on brand.primary. AnimatedSplashOverlay (src/components/animated-icon.tsx) shows the same image for the first frame and fades it out, so both stay in sync.

Optional: an Icon Composer icon for iOS

iOS uses assets/images/icon.png like every other platform. To ship a layered Icon Composer icon instead, export a .icon file to assets/expo.icon: app.config.ts sets ios.icon to it only when that file exists, and gen:assets never touches it.

The name, ids, icon and splash are native, so rebuild to see them. bun run ios compiles the dev build locally (it needs Xcode; bun run android needs Android Studio), or you can build one on EAS - see Expo Go or a dev build.

bun run ios

You should see: your icon and "Trailmix (Dev)" on the home screen, and your splash on launch.

6. Rewrite the onboarding

The three slides are the SLIDES array at the top of src/screens/onboarding/onboarding-screen.tsx. Each slide has an SF Symbol (icon), a Material fallback for Android and web (fallback), and two strings; {{name}} becomes your app name.

src/screens/onboarding/onboarding-screen.tsx
const SLIDES: readonly Slide[] = [
  {
    icon: "map",
    fallback: "map",
    title: "Welcome to {{name}}",
    description: "Log every trail you walk, with the photos and notes that go with it.",
  },
  {
    icon: "camera",
    fallback: "photo_camera",
    title: "Snap as you go",
    description: "Photos land on the trail they were taken on - no sorting later.",
  },
  {
    icon: "checkmark.seal",
    fallback: "verified",
    title: "You're all set",
    description: "Start your first trail from the Home tab.",
  },
];

The strings are i18n keys (the English text is the key). With an i18n module selected, add each new string to every src/locales/*.json - a test checks all locales have the same keys - and delete the old slide keys.

You should see: in Settings → Reset onboarding, then a relaunch, your three slides.

Check it

bun run doctor

It warns while app.name is still ReadyNative, the ids are still com.acme.app, or the urls and store fields are empty. Fill those in before your first store build - the Settings screen links to urls.privacy, urls.terms and urls.support. The Free tier has no doctor, so read those fields in readynative.config.ts yourself. Then:

bun run typecheck

If it doesn't work

  • Colours didn't change - run bun run gen:theme again (on Free, check the --primary triple you edited in src/global.css), then restart with bun run start -- -c. NativeWind caches the old CSS.
  • The icon or name is still ReadyNative's - you're in Expo Go, or in a dev build made before the change. Rebuild with bun run ios / bun run android or a new EAS build.
  • gen:assets hangs or fails - it needs Node >= 22.18 and a square PNG of at least 1024 px at assets/brand/icon.png. On Free there is no gen:assets: the files you replaced in assets/images/ must keep their names and be square PNGs.
  • "Unable to resolve ../../assets/fonts/…" - the path in require() is relative to src/app/_layout.tsx, and the file name must match exactly, case included.
  • A locale test fails after editing the slides - with an i18n module and jest, a new string is missing from one of the src/locales/*.json files. The Free tier has neither.

More in Troubleshooting.

Congrats 🎉

It's your app now: your name, ids, colours in both modes, icon, splash, font and first-run copy. Next, build something in it: Add your first feature.

On this page

Get ReadyNative