Skip to main content

Theme and context

Two things every screen shares: the theme, the colors of the bars and backgrounds, and the context, what every screen can rely on. Why the navigation keeps them, how to use them, and what belongs in them.

SectionTheme and contextDraftDocumented, not reviewed yet: the API can change.

Every screen of an app shares a look and a few facts: the colors of the bars, the signed-in user, the language. The navigation keeps both for every screen. The theme is the colors of the bars, tabs and backgrounds. The context is a small object of the app that every screen reads. This page answers:

  1. Why the navigation keeps them, and not a React provider.
  2. The theme: its colors, dark mode, changing it, Android's theme.
  3. The context: filling it, reading it, typing it.
  4. What goes in the context, and what goes elsewhere.

Why the navigation keeps them​

Each screen renders in a React root of its own, as the platform shows it (see What a screen is). A React provider around one screen doesn't reach the next one, and the bars aren't React at all. So what every screen shares sits above the screens, with the navigation. From there it reaches every screen, the options of every screen, and the native bars.

Providers of your own, such as a store or a query client, have a place of their own: see One tree.

The theme​

The theme is the colors of the navigation: the bars, the tabs, the backgrounds. It is set once in createNavigation, and every screen takes it:

Navigation.ts
import { createNavigation } from '@apexnative/navigation';

export const Navigation = createNavigation({
// … screens and root
theme: {
colors: {
primary: '#E76F51',
bar: '#1E2A44',
text: '#F3E9D2',
background: '#F3E9D2',
},
},
});

Every top bar and the tab bar are navy with light titles; the buttons, the back arrow and the selected tab are orange.

primaryThe buttons, the back button, the selected tab, the cursor of the search.
barThe top bar and the tab bar. Left out: the glass of iOS 26.
textThe titles.
backgroundBehind every screen.
borderThe lines under the top bar and over the tab bar.
badgeThe badges of the tabs.
iOS
Android

A color left out keeps the platform's look. On iOS 26 a theme without bar keeps the bars on glass. On Android the bars take the colors of the app's Material 3 theme, neutral grays by default (see Android's theme).

The theme is the bottom layer of the options of every screen. A screen that needs its own look sets it in its options, and they go over the theme (see Where a screen gets them).

Dark mode​

A color can have a value for each appearance, { light, dark }. The native side picks the one of the system's appearance, and switches by itself when the user turns dark mode on, with no render in JavaScript:

colors: {
bar: { light: '#FFFAF0', dark: '#101828' },
text: { light: '#1E2A44', dark: '#F3E9D2' },
}

Changing the theme​

navigation.setTheme changes the theme while the app runs, such as when the user picks another one in the settings. The screens already shown take it too. It replaces the whole theme, so a color left out goes back to the platform's look. Keep each theme as a whole object, and pass one:

src/themes.ts
import type { NavigationTheme } from '@apexnative/navigation';

export const ocean: NavigationTheme = {
colors: {
primary: '#2A9D8F',
bar: '#0B3C49',
text: '#F1FAEE',
background: '#F1FAEE',
},
};
src/screens/Settings.tsx
navigation.setTheme(ocean);

A screen whose own options set a color keeps it: its options go over the theme.

The theme in React content​

React content that should match the bars reads the theme with useNavigationTheme(). Each color comes resolved for the current appearance, so it goes straight into a style. The component renders again when the theme changes, and when the appearance turns light or dark:

src/Card.tsx
import { Text, View } from 'react-native';
import { useNavigationTheme } from '@apexnative/navigation';

export function Card({ title }: { title: string }) {
const { colors } = useNavigationTheme();
return (
<View style={{ backgroundColor: colors.bar, padding: 16 }}>
<Text style={{ color: colors.text }}>{title}</Text>
</View>
);
}

A color the theme leaves out is undefined here, so the style falls back to its default.

The theme in options​

The options of the app and of a screen can be built from the theme: their function gets { theme, context } (see The chain and plain objects). They differ in when they are built:

OptionsBuilt
options of createNavigationAt the start, and again on every setTheme.
A screen's own optionsWhen a command shows the screen. A later setTheme doesn't build them again.

So a color that follows the theme belongs in the theme itself, or in the app's options.

Android's theme​

Android The bars, tabs, search, sheets, menus and the floating action button are Material 3 views. What the theme and the options leave out, they take from the app's Android theme, as every Material view of the app does. On an app made from React Native's template, setup makes the app's theme inherit Theme.ApexNavigation: Material 3 in neutral grays, light and dark, with no tint of its own.

An app changes any of these colors in styles.xml. A color that differs in dark mode is a color resource with a second value in values-night, so the one style serves both modes:

android/app/src/main/res/values/styles.xml
<style name="AppTheme" parent="Theme.ApexNavigation">
<item name="colorSecondaryContainer">@color/selected_tab</item>
</style>
android/app/src/main/res/values/colors.xml
<color name="selected_tab">#FCE4D6</color>
android/app/src/main/res/values-night/colors.xml
<color name="selected_tab">#5C3B2E</color>

The selected tab now sits on a peach pill in light mode, and a brown one in dark mode. What each Material 3 color paints:

Material 3 colorWhat takes it
colorSurfaceThe top bar, and the menus of the bar while the bar is opaque.
colorOnSurfaceThe title, the large title, the back arrow, the text of the search.
colorOnSurfaceVariantThe subtitle and the "⋮" of the bar; the hint of the search.
colorSurfaceContainerThe tab bar; the menus of the bar when the bar lets the screen through.
colorSurfaceContainerLowA sheet.
colorSurfaceContainerHighThe search field and its suggestions.
colorSecondaryContainerThe pill of the selected tab, the selected scope of the search.
colorPrimaryContainerThe floating action button.
colorErrorThe badges of the tabs, the destructive items of the bar's menus.
android:colorBackgroundBehind a screen without layout.backgroundColor. It comes from the activity's own theme, Material 3 or not; a screen in a sheet shows the sheet's color.

The theme's colors and a screen's options go over these: bar over the top bar and the tab bar, text over the titles, badge over the badges. The menus of the bar take the bar's color and the title's color while the bar is opaque; topBar.menuBackgroundColor and topBar.menuTextColor set them apart. These two are Android only: iOS draws its menus in the system's colors.

  • An app with a Material 3 theme of its own keeps it, and the navigation takes its colors.
  • An app with a theme that isn't Material 3 keeps it too, and the navigation's views take Theme.ApexNavigation (see the next section).
  • An app that is only light sets it in code: AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO). Theme.ApexNavigation follows dark mode, so without it the navigation's views turn dark with the device.

An app with an AppCompat or MaterialComponents theme​

Many apps keep a theme of their own, such as Theme.AppCompat.Light.NoActionBar or Theme.MaterialComponents.DayNight.NoActionBar. setup leaves it as it is: it changes only the template's Theme.AppCompat.DayNight.NoActionBar.

The navigation tells a Material 3 theme by colorSurfaceContainerHigh, a role only Material 3 themes have. When the activity's theme has none, each view of the navigation is made in a copy of the app's theme with Theme.ApexNavigation laid over it. Over it means it wins: the colors, text appearances and shapes it sets replace the app's, so the app's colorPrimary and colorAccent don't reach the bars, and the bars are neutral gray next to the app's own colors. The app's own views keep its theme.

There are two ways to give the bars the app's colors.

Move the theme to Material 3. The parent becomes Theme.ApexNavigation, or Theme.Material3.DayNight.NoActionBar for Material's own palette, and the brand colors go to Material 3's roles, the ones in the table above:

android/app/src/main/res/values/styles.xml
<!-- Before: parent="Theme.MaterialComponents.DayNight.NoActionBar" -->
<style name="AppTheme" parent="Theme.ApexNavigation">
<item name="colorSecondaryContainer">@color/selected_tab</item>
<item name="colorPrimaryContainer">@color/brand</item>
</style>

The selected tab and the floating action button take the brand colors. Attributes Material 3 has no role for, such as colorAccent, colorPrimaryDark or colorPrimaryVariant, do nothing in the navigation. The app's own native views change with the theme too: its AppCompat and Material widgets and dialogs take the Material 3 look.

Keep the theme, and color the navigation from JavaScript. The colors of the theme are options, not Android theme attributes, so they paint the bars with any Android theme:

Navigation.ts
export const Navigation = createNavigation({
// … screens and root
theme: {
colors: {
primary: '#E76F51',
bar: '#1E2A44',
text: '#F3E9D2',
badge: '#E63946',
},
},
});

The bars, the titles, the buttons, the selected tab and the badges take the brand colors. The rest stays in Theme.ApexNavigation's grays: the pill of the selected tab, a sheet, the search field, the floating action button. The navigation has no theme attribute or overlay of its own for them. Two of them have an option of their color: fab.backgroundColor and topBar.searchBar.backgroundColor; the pill of the selected tab and a sheet have none.

The way to recolor them in resources is to replace Theme.ApexNavigation itself: a style of the same name in the app wins over the library's when the build merges the resources. It replaces the whole style, so it has Material 3's parent, and it goes in values and again in values-night, or dark mode keeps the library's grays:

android/app/src/main/res/values/themes.xml
<style name="Theme.ApexNavigation"
parent="Theme.Material3.DayNight.NoActionBar">
<item name="colorSecondaryContainer">#FCE4D6</item>
</style>
android/app/src/main/res/values-night/themes.xml
<style name="Theme.ApexNavigation"
parent="Theme.Material3.DayNight.NoActionBar">
<item name="colorSecondaryContainer">#5C3B2E</item>
</style>

A role the app's style leaves out takes Material 3's own palette, with its violet tint, and not the navigation's grays.

Fonts and shapes​

An app with a font of its own sets it in the options, which work with any Android theme. The app's options give it to every screen:

Navigation.ts
export const Navigation = createNavigation({
// … screens and root
options: {
topBar: {
title: { fontFamily: 'Inter', fontWeight: '600' },
subtitle: { fontFamily: 'Inter' },
largeTitle: { fontFamily: 'Inter' },
},
bottomTab: { fontFamily: 'Inter' },
},
});

The family is the name a Text style takes: a font in assets/fonts/ or a res/font resource React Native knows. Without options, each text takes its font from the app's theme, or from Material 3's text appearances:

TextWithout optionsOptions over it
The title and the subtitle22 sp and 14 sp, set in code; the theme's android:fontFamily, else the system font.topBar.title.fontFamily, fontSize, fontWeight, fontStyle; the same in topBar.subtitle.
The large title28 sp, set in code; the font as the title.The same in topBar.largeTitle.
A text button of the barThe theme's actionMenuTextAppearance.fontFamily, fontSize, fontWeight, fontStyle of the button.
The labels of the tabstextAppearanceLabelMedium, through Material 3's style of the tab bar.bottomTab.fontFamily, fontWeight, fontStyle; fontSize and selectedFontSize.
The items of the bar's menustextAppearanceLargePopupMenu, which Material 3 points to textAppearanceBodyLarge; the title of a submenu textAppearancePopupMenuHeader.None.
The text of the search16 sp. The field in the bar takes the font as the title; the pill under the title, Material's SearchBar, its own TextAppearance.Material3.SearchBar.None.
The scopes of the searchtextAppearanceLabelLarge.None.

The shapes come mostly from code, not from shapeAppearance… attributes:

ShapeWithout optionsOption over it
A sheet28 dp, the top corners only, set in code.modal.sheet.cornerRadius.
The menus of the barThe theme's popupMenuBackground, a drawable: 4 dp in Material 3.None.
The search fieldA capsule: set in code in the bar, Material's SearchBar style under the title; the suggestions 16 dp, set in code. While the search fills the screen, Material's SearchView has no corners.None.
The scopes of the searchshapeAppearanceCornerSmall.None.
The tab barSquare.bottomTabs.cornerRadius.
The floating action buttonshapeAppearanceCornerLarge.None.

An app with a Material 3 theme changes the theme's part in styles.xml. These attributes are the app's own: its other views that use them change too.

android/app/src/main/res/values/styles.xml
<style name="AppTheme" parent="Theme.ApexNavigation">
<item name="android:fontFamily">@font/inter</item>
<item name="textAppearanceLabelMedium">@style/TabLabel</item>
<item name="popupMenuBackground">@drawable/menu</item>
</style>

<style name="TabLabel" parent="TextAppearance.Material3.LabelMedium">
<item name="fontFamily">@font/inter</item>
<item name="android:fontFamily">@font/inter</item>
</style>

The title, the large title and the labels of the tabs are in Inter, and so is the search field in the bar from Android 8 on: before it, a plain text field can't load a font resource. The menus of the bar take the shape of menu.xml, in the bar's color while the bar is opaque.

In an app whose theme isn't Material 3, Theme.ApexNavigation lies over the app's theme, so the text appearances, popupMenuBackground and the shapes are Theme.ApexNavigation's whatever the app's theme says. Theme.ApexNavigation doesn't set android:fontFamily, so the app's own still reaches the title, the large title and the search field in the bar. For the rest, the options are the way.

The context​

The context is a small object of the app that every screen, every option and the start of the app can read: who the user is, the language, the flags of the app. The app gives its first value in createNavigation, and a component reads it with useAppContext:

Navigation.ts
import { createNavigation } from '@apexnative/navigation';

export type AppContext = {
language: string;
user: { name: string } | null;
};

const context: AppContext = { language: 'en', user: null };

export const Navigation = createNavigation({
// … screens and root
context,
});
src/screens/Home.tsx
import { Text } from 'react-native';
import { useAppContext } from '@apexnative/navigation';
import type { AppContext } from '../../Navigation';

export function Home() {
const user = useAppContext((c: AppContext) => c.user);
return <Text>Hello, {user?.name ?? 'guest'}</Text>;
}

Home shows "Hello, guest", and the user's name once a sign-in puts the user in the context. Once Navigation.ts exports the type, the generated file registers it: useAppContext, setContext and the options know it by themselves, and the AppContext annotation goes; the examples below assume it.

Why it exists​

Some values are needed before any screen renders, and by every screen after it. Whether the user is signed in decides the first screen; the user's name goes into the title of a bar; the language changes every screen at once.

In a React app such values sit in a provider at the top of the tree. Here each screen is a React root of its own, so a provider doesn't reach them, and the start of the app and the options of a screen aren't React at all. The context is the one place all of them read:

Who reads itHow
The start of the approot(context), initialRoot(context): the first screen from the context.
The options of a screen(s, props, { context }): a title or a button from the context.
A component of any screenuseAppContext(), which renders again when it changes.
Code outside Reactnavigation.getContext().

Filling it​

The context has a first value, and one way to change it:

WhereThe context
context of createNavigationThe first value, there at once: while willMount runs, and in an app without willMount. Its type is the type of the context.
navigation.setContextThe only way to change it: in willMount before the first screen, after a sign-in, from the settings.

A value the first screen depends on goes in during willMount, which runs while the splash screen shows. The first root is then built from it:

Navigation.ts
import { createNavigation, navigation } from '@apexnative/navigation';

export const Navigation = createNavigation({
// … screens and roots
context,
willMount: async () => {
const user = await loadUser(); // your own check
navigation.setContext((previous) => ({ ...previous, user }));
},
initialRoot: (context) => (context.user ? 'main' : 'login'),
});

A signed-in user starts on main, anyone else on login, and no screen shows before the check is done.

setContext takes the new value, or a function of the value before. The function keeps the other keys:

src/screens/Login.tsx
const signIn = async (email: string, password: string) => {
const user = await logIn(email, password); // your own request
navigation.setContext((previous) => ({ ...previous, user }));
await navigation.switchTo('main');
};
src/screens/Settings.tsx
const changeLanguage = (language: string) =>
navigation.setContext((previous) => ({ ...previous, language }));

Reading it​

useAppContext with a function picks the part a component needs, so the component renders only when that part changes. A change of the language doesn't render a component that reads the user. Without the function, it renders on every change of the context.

The options of a screen read the context too, once, when a command shows the screen:

src/screens/Home.tsx
export const HomeScreen = defineScreen({
component: Home,
options: (s, props, { context }) =>
s.title(context.user?.name ?? 'Home'),
});

A later setContext doesn't change the title of a screen already shown. A title that should follow the context reads it in the component, with useScreenOptions, which applies options again on each render (see From the state):

src/screens/Home.tsx
function Home() {
const user = useAppContext((c) => c.user);
useScreenOptions((s) => s.title(user ? `Hi, ${user.name}` : 'Home'));
return <Feed />;
}

The title changes the moment the user signs in or out, on every screen that shows Home.

Typing it​

TypeScript learns the type of the context from navigation-env.d.ts, the file setup and init write to type the commands (see Typed commands). It registers the app from typeof Navigation, and the context from the type or interface that Navigation.ts exports under the name AppContext, as above. A type of another name isn't picked up. Nothing to add by hand:

navigation-env.d.ts
// Made by apex-navigation setup: the types of the app's screens
// for the navigation's commands. Don't edit.
declare module '@apexnative/navigation' {
interface Register {
app: typeof import('./Navigation').Navigation;
context: import('./Navigation').AppContext;
}
}
export {};

Then useAppContext(), getContext(), setContext() and the options are typed with it. The context is registered on its own, not read from the app's type, because the options of the screens are part of that type and would refer to it in a circle.

What goes in the context​

The context is made for what the whole app depends on, and what changes rarely. In an app it has four roles:

RoleFor example
Where the user goesSigned in or not, the onboarding done or not: the first root, and the root after a sign-in or a sign-out.
What every screen shows the sameThe user's name and avatar, the language, the plan of the account.
What the app turns on and offFeature flags and remote configuration, read once at the start.
Services the screens useObjects the app creates once at the start and every screen calls: dependency injection, without a global import.

What to keep in it​

Keep in the context
  • The signed-in user: the id, the name, the avatar, the role.
  • The session: signed in or not, the onboarding done or not.
  • The language and the region.
  • Feature flags and remote configuration.
  • The plan or the entitlements of the account.
  • The environment: staging or production.
  • Services created once at the start.
Keep elsewhere
  • Lists, messages, feeds: a store, or the cache of a query library.
  • What a screen edits, such as a form or a draft: the state of the screen.
  • What changes many times a second: a timer, a progress, a position.
  • Which screen shows: read it with getState, don’t mirror it.
  • Secrets such as tokens: the keychain or secure storage.

In code​

The same three cases, each as it goes wrong and as it goes right.

Data that keeps coming, such as new messages, goes to a store. In the context, each message would render every screen that reads it:

Not recommended
socket.on('message', (message) =>
navigation.setContext((c) => ({
...c,
messages: [...c.messages, message],
})),
);
Recommended
socket.on('message', (message) =>
messages.add(message), // your own store
);

What one screen edits, such as a draft, stays in that screen. In the context, each letter would render every screen that reads it:

Not recommended
<TextInput
onChangeText={(draft) =>
navigation.setContext((c) => ({
...c,
draft,
}))
}
/>
Recommended
const [draft, setDraft] = useState('');

<TextInput onChangeText={setDraft} />;

Who the user is goes to the context once, when it changes, instead of every screen loading it again on its own:

Not recommended
function Profile() {
const [user, setUser] = useState(null);
useEffect(() => {
loadUser().then(setUser);
}, []);
return <Text>{user?.name}</Text>;
}
Recommended
function Profile() {
const user = useAppContext((c) => c.user);
return <Text>{user?.name}</Text>;
}

Why not everything​

The context is made for few values that change rarely, and it behaves so:

  • A change renders its readers on every screen. A component that reads the changed part renders again on every mounted screen: the screens under the one on top and in the other tabs too. A list kept here renders the whole app on each new message.
  • It isn't saved. A new start of the app begins from context again. What should outlive a restart, the app saves itself, and puts back in willMount.
  • It has no tools of a store. No middleware, no devtools, no history of changes. A store has them, and the context can hold the store itself as a service.

Compared with the other places​

The app's contextA storeReact context in appProvideruseState
Read byEvery screen, the options, the start, code outside ReactComponents, and code that imports itComponents inside the providerOne component
Before the first screenYesYesNoNo
ChangesRarely, with setContextOften, by its actionsBy its providerOften
ForWho the user is, the language, the flags, servicesThe data of the appState shared through ReactOne screen

API​