Sitemap
Nikhil Gautam

Nikhil Gautam

Theme System Guide For React Native

This document is the single source of truth for how theming works in this project. Read it before writing your first screen or component. If something is unclear after reading, the code itself is the final authority.

15 min readJul 1, 2026

--

Table of Contents

  1. Why a Theme System?
  2. Architecture at a Glance
  3. The Two-Layer Colour Model
  4. Every File Explained
  5. ThemeProvider — How the Theme is Delivered
  6. useTheme() — Your Main Tool
  7. Reusable Components
  8. Using Colours
  9. Using Typography
  10. Using Spacing, Radius, Sizing, and Shadows
  11. Building a New Screen
  12. Building a New Component
  13. How to Modify the Theme
  14. Adding Custom Fonts
  15. Best Practices
  16. Common Mistakes

1. Why a Theme System?

Without a theme system, colours, fonts, and spacing values get hardcoded across hundreds of files. When the brand changes — or when you need a dark mode — you end up hunting through every file and changing values by hand, which is error-prone and time-consuming.

A theme system solves this by creating one place to define all visual values, and giving every component a single hook (useTheme()) to read those values. Changing the brand's primary colour in one file then updates every button, every heading, and every border across the entire app automatically.

This project uses a semantic token approach: instead of writing color: '#7C2040' in your components, you write color: colors.accent. The token name describes what the colour means, not what it looks like. This is what makes dark mode work — the same token name (accent) resolves to different actual colours in light and dark mode.

2. Architecture at a Glance

The theme system is split into five layers, each depending only on the layers below it:

┌─────────────────────────────────────────────────────────┐
│ Layer 5 · Components │
│ AppText, AppView, AppSafeView, AppPressable, … │
│ src/shared/components/ │
├─────────────────────────────────────────────────────────┤
│ Layer 4 · Hook + Provider │
│ useTheme() ← ThemeProvider │
│ src/hooks/useTheme.ts │
│ src/shared/providers/ThemeProvider.tsx │
├─────────────────────────────────────────────────────────┤
│ Layer 3 · TypeScript Types │
│ Theme, ColorTokens, ThemeMode, TextVariant, … │
│ src/types/theme.types.ts │
├─────────────────────────────────────────────────────────┤
│ Layer 2 · Resolved Themes (Semantic Tokens) │
│ lightTheme, darkTheme │
│ src/theme/light.ts src/theme/dark.ts │
├─────────────────────────────────────────────────────────┤
│ Layer 1 · Primitives (Raw Values) │
│ palette, typography, spacing, radius, sizing, shadows │
│ src/theme/colors.ts src/theme/typography.ts etc. │
└─────────────────────────────────────────────────────────┘

The golden rule: components only talk to Layer 4 (the hook). They never reach down to Layer 1 or 2 directly.

3. The Two-Layer Colour Model

Colours are defined in two stages. Understanding this is the most important concept in the system.

Stage 1 — The Palette (src/theme/colors.ts)

The palette is the complete list of every raw colour the brand allows. Each name describes the hue and its intensity level (50 = lightest, 900 = darkest):

rose50, rose100, rose200, … rose900
lavender100, lavender200, … lavender500
claret50, claret100, … claret900
forest100, … forest900
crimson100, … crimson900
neutral50, … neutral900
white, black

Rule: Never import palette inside a component or a screen. The palette is only used in light.ts and dark.ts.

Stage 2 — Semantic Tokens (src/theme/light.ts and src/theme/dark.ts)

Semantic tokens give each colour a meaning. The same token (accent) maps to a different palette colour depending on which theme is active:

TokenLight valueDark valuebackgroundpalette.white (#FFFFFF)palette.neutral900 (#160C12)primarypalette.rose400 (#C9A4B2)palette.rose300 (#D9B8C7)accentpalette.claret700 (#7C2040)palette.claret300 (#C06280)textPrimarypalette.neutral900 (#160C12)palette.rose50 (#FDF8F9)borderpalette.neutral200 (#E8E0E3)palette.neutral700 (#3A2E33)

When you use colors.accent in a component, React reads whichever resolved theme is currently active. Your code never needs an if (isDark) check for colour selection.

4. Every File Explained

src/
├── types/
│ └── theme.types.ts All TypeScript interfaces for the theme
│
├── theme/
│ ├── colors.ts Raw colour palette (Stage 1) — 60+ named colours
│ ├── typography.ts Font families, sizes, weights, lineHeights, textVariants
│ ├── spacing.ts 4-point spacing grid (xs → 7xl)
│ ├── sizing.ts Fixed dimensions: icons, buttons, inputs, avatars
│ ├── radius.ts Border radius scale (xs → full)
│ ├── shadows.ts Shadow definitions for light and dark mode
│ ├── light.ts Light theme — maps semantic tokens to palette values
│ ├── dark.ts Dark theme — maps semantic tokens to palette values
│ └── index.ts Barrel re-export for the whole theme directory
│
├── shared/
│ ├── providers/
│ │ └── ThemeProvider.tsx React context provider — manages active theme state
│ └── components/
│ ├── AppText.tsx Themed text component
│ ├── AppView.tsx Themed view container
│ ├── AppSafeView.tsx Themed safe-area-aware screen root
│ ├── AppPressable.tsx Themed touchable with press feedback
│ ├── AppScrollView.tsx Themed scroll container
│ └── index.ts Barrel re-export for all components
│
└── hooks/
└── useTheme.ts The main hook every component calls

src/types/theme.types.ts

Pure TypeScript — no runtime code. Defines the shape of everything: Theme, ColorTokens, ShadowStyle, ThemeMode, TextVariant, etc. All other files import their types from here, which keeps interfaces consistent.

src/theme/colors.ts

The brand’s full colour vocabulary, named by hue and shade number. Only light.ts and dark.ts should import from this file.

src/theme/typography.ts

Three things live here:

  • fontFamilies — the actual font family strings resolved per platform (Georgia/serif for headings, system sans-serif for body)
  • textVariants — 14 pre-built text style definitions (display, h1–h4, subheading, bodyLg, body, bodySm, label, labelSm, caption, button, buttonSm, overline)
  • fontSizes, fontWeights, lineHeights, letterSpacings — raw scale values used to compose variants

src/theme/spacing.ts

A 4-point grid from none (0) to 7xl (80). All padding, margin, and gap values in the app must come from this scale. Using arbitrary numbers like paddingLeft: 13 is not allowed.

src/theme/sizing.ts

Named size buckets for specific UI elements: icons (sizing.icon.lg = 24), buttons (sizing.button.md = 44), inputs, avatars, and thumbnails. These keep element sizes consistent across screens.

src/theme/radius.ts

Border radius scale from none (0) to full (9999). Use radius.md (8) for cards, radius.full for pills and avatars.

src/theme/shadows.ts

Two sets of shadow definitions — lightShadows and darkShadows — each with five levels (none, sm, md, lg, xl). Shadows include both iOS (shadowColor, shadowOpacity, etc.) and Android (elevation) properties. The correct set is automatically selected by light.ts and dark.ts.

src/theme/light.ts and src/theme/dark.ts

These are the theme objects. Each imports from colors.ts and shadows.ts and assigns a palette value to every semantic token. When you add a new semantic colour, you add it to both files and to the ColorTokens interface in theme.types.ts.

src/theme/index.ts

A single barrel export. Any file in the app can import everything it needs from '@/theme':

import { spacing, radius, lightTheme, textVariants } from '@/theme';

5. ThemeProvider — How the Theme is Delivered

ThemeProvider sits at the top of the component tree (in src/app/_layout.tsx) and makes the current theme available to every component below it through React Context.

Here is what it does internally:

User preference (themeMode): 'light' | 'dark' | 'system'
↓
System OS setting: useColorScheme() → 'light' | 'dark'
↓
Resolution logic:
- If themeMode is 'light' → use lightTheme
- If themeMode is 'dark' → use darkTheme
- If themeMode is 'system' → use whichever the OS reports
↓
ThemeContext.Provider sends { theme, themeMode, setThemeMode, isDark }
to every child component

You will almost never interact with ThemeProvider directly. Your screens use useTheme() instead.

Get Nikhil Gautam’s stories in your inbox

Join Medium for free to get updates from this writer.

The only place ThemeProvider appears in the codebase is _layout.tsx:

// src/app/_layout.tsx
export default function RootLayout() {
return (
<ThemeProvider>
<AppNavigator />
</ThemeProvider>
);
}

Persisting the theme choice across restarts

By default, the chosen mode resets to 'system' when the app restarts. To persist it, open ThemeProvider.tsx and add AsyncStorage reads/writes at the two marked spots:

// On mount: read saved mode
// On change: save new mode
const setThemeMode = useCallback((mode: ThemeMode) => {
setThemeModeState(mode);
AsyncStorage.setItem('themeMode', mode); // ← add this
}, []);

6. useTheme() — Your Main Tool

useTheme() is the single hook every screen and component calls. It returns everything you need:

import { useTheme } from '@/hooks/useTheme';
const {
colors, // all semantic colour tokens for the current mode
shadows, // sm / md / lg / xl shadow styles for the current mode
spacing, // the spacing scale (identical in both modes)
sizing, // named element dimensions
radius, // border radius scale
textVariants, // raw text style definitions (used internally by AppText)
fontFamilies, // resolved font family strings
themeMode, // current setting: 'light' | 'dark' | 'system'
setThemeMode, // call this to switch themes
isDark, // boolean — true when the dark theme is active
} = useTheme();

colors and shadows change when the theme switches. Everything else (spacing, sizing, radius, etc.) is static — it does not change between modes.

7. Reusable Components

All base components live in src/shared/components/ and are exported through the barrel at src/shared/components/index.ts. Always import from the barrel:

import { AppText, AppView, AppSafeView, AppPressable, AppScrollView } from '@/shared/components';

AppText

The only way to render text in this app. It reads the current theme automatically.

// Default: 'body' variant, textPrimary colour
<AppText>Hello world</AppText>
// Named variant
<AppText variant="h2">Village</AppText>
// Semantic colour override
<AppText variant="body" color="textSecondary">Supporting copy</AppText>
// Decorative props
<AppText variant="overline" uppercase color="textMuted">Category</AppText>
<AppText variant="body" italic>Quoted text</AppText>
<AppText variant="body" underline>Link-style text</AppText>
// Text alignment
<AppText variant="body" align="center">Centered</AppText>
// Plus any standard React Native Text prop (numberOfLines, onPress, …)
<AppText variant="body" numberOfLines={2}>Long paragraph…</AppText>

Full list of variants:

VariantFontSizeWeightTypical usedisplaySerif48BoldHero / splash headingsh1Serif36BoldPage titlesh2Serif30BoldSection headingsh3Serif24BoldSub-sectionsh4Serif20SemiboldCard titlessubheadingSans17SemiboldBold intro linesbodyLgSans17RegularLarger paragraphsbodySans15RegularDefault body copybodySmSans13RegularSecondary bodylabelSans13SemiboldForm labels, tagslabelSmSans11SemiboldTiny labelscaptionSans11RegularHints, timestampsbuttonSans15SemiboldButton textbuttonSmSans13SemiboldSmall button textoverlineSans11SemiboldSection labels (ALL CAPS)

AppView

A View that accepts a bg prop for theme-aware background colours.

// Explicit background
<AppView bg="surface" style={{ padding: spacing.lg }}>
<AppText>Content</AppText>
</AppView>
// No background (same as plain View)
<AppView style={{ flexDirection: 'row', gap: spacing.sm }}>

AppSafeView

Use this as the root container of every screen. It wraps SafeAreaView from react-native-safe-area-context and applies the theme background by default.

export default function MyScreen() {
return (
<AppSafeView>
{/* screen content */}
</AppSafeView>
);
}
// Custom background
<AppSafeView bg="backgroundAlt">

AppPressable

Use this for anything tappable. Provides an Android ripple effect and an iOS opacity press state.

<AppPressable onPress={handlePress} style={styles.card}>
<AppText variant="label">Tap me</AppText>
</AppPressable>
// Custom ripple colour
<AppPressable onPress={handlePress} rippleColor={colors.accent + '30'}>
// Disabled state (automatically reduces opacity)
<AppPressable onPress={handlePress} disabled={isLoading}>

AppScrollView

A themed scroll container. Sets background colour and hides scroll indicators by default.

<AppSafeView>
<AppScrollView>
{/* content */}
</AppScrollView>
</AppSafeView>
// Horizontal scroll
<AppScrollView horizontal>
// Custom background
<AppScrollView bg="backgroundAlt">

8. Using Colours

Always read colours from useTheme(). Never write a hex value directly in a component.

// Correct
const { colors } = useTheme();
<AppView style={{ backgroundColor: colors.surface }} />
<AppView style={{ borderColor: colors.border }} />
// Wrong — hardcoded hex value
<AppView style={{ backgroundColor: '#FDFAFB' }} />

Full colour token reference:

GroupTokensWhen to useBackgroundsbackgroundMain screen backgroundbackgroundAltAlternate sections, tab backgroundssurfaceCards, list items, input fieldssurfaceElevatedModals, popovers, sheetsBrandprimaryBrand highlights, selected statesprimaryLightTinted backgrounds behind primary elementsprimaryDarkPressed / hover state of primarysecondarySecondary accents, tagssecondaryLightTinted backgrounds behind secondary elementsaccentCTA buttons, active nav items, linksaccentLightLighter variant of accent for hoveraccentDarkDarker variant of accent for pressStatussuccess / successLight / successBgConfirmation, completed stateserror / errorLight / errorBgErrors, destructive actionswarning / warningLight / warningBgWarnings, attention neededinfo / infoBgInformational messagesTexttextPrimaryHeadings, primary body copytextSecondarySupporting copy, descriptionstextMutedHints, timestamps, placeholder texttextDisabledDisabled form fieldstextInverseText on dark backgrounds (light mode)textOnPrimaryText placed directly on a primary-coloured surfacetextOnAccentText placed directly on an accent-coloured surfaceUI ChromeborderDefault borders on cards and inputsborderLightVery subtle dividersborderFocusActive/focused input borderdividerHorizontal rule linesoverlaySemi-transparent backdrop for modalsIconsiconDefault icon colouriconActiveActive / selected iconiconMutedInactive / decorative icon

9. Using Typography

The quick way: AppText with a variant

In 95% of cases you just pick a variant and you’re done:

<AppText variant="h1">Page Title</AppText>
<AppText variant="body" color="textSecondary">Description text</AppText>
<AppText variant="caption" color="textMuted">Created 2 days ago</AppText>

The manual way: applying variants to StyleSheet

When you need the raw text style values — for example inside a StyleSheet.create block or to combine with another style — read textVariants from useTheme():

const { textVariants, fontFamilies, colors } = useTheme();
const bodyStyle = {
fontFamily: fontFamilies.sans,
fontSize: textVariants.body.fontSize, // 15
fontWeight: textVariants.body.fontWeight, // '400'
lineHeight: textVariants.body.lineHeight, // 26
color: colors.textPrimary,
};

This pattern is rare. Prefer <AppText> wherever possible.

Font stacks

Two font stacks are defined:

  • Serif (fontFamilies.serif) — used for display, h1, h2, h3, h4. Currently Georgia (system fallback). Designed to match Libre Baskerville from the brand guide.
  • Sans (fontFamilies.sans) — used for all body and UI text. Currently the platform system font (San Francisco on iOS, Roboto on Android). Designed to match Dora from the brand guide.

See Section 14 for how to swap in the real brand fonts.

10. Using Spacing, Radius, Sizing, and Shadows

All four are available from useTheme().

Spacing

The spacing scale is a 4-point grid. Every gap, padding, and margin in the app must use a value from this scale.

const { spacing } = useTheme();
<AppView style={{
padding: spacing.lg, // 16
paddingHorizontal: spacing['2xl'], // 24
marginBottom: spacing.sm, // 8
gap: spacing.md, // 12
}} />

KeyValueTypical usenone0No spacexs4Between icon and labelsm8Between list items, small gapsmd12Internal component paddinglg16Standard section paddingxl20Comfortable element spacing2xl24Card padding3xl32Between sections4xl40Top/bottom of major sections5xl48Screen-level vertical padding6xl64Large layout gaps7xl80Hero / splash spacing

Border Radius

const { radius } = useTheme();
<AppView style={{ borderRadius: radius.md }} />   // chips, badges → 8
<AppView style={{ borderRadius: radius.lg }} /> // cards → 12
<AppView style={{ borderRadius: radius.xl }} /> // modals, sheets → 16
<AppView style={{ borderRadius: radius.full }} /> // pills, avatars → 9999

Sizing

Use sizing for consistent element dimensions rather than arbitrary numbers:

const { sizing } = useTheme();
// Icon dimensions
<Icon width={sizing.icon.lg} height={sizing.icon.lg} /> // 24×24
// Touch target heights (important for accessibility — minimum 44pt)
<AppPressable style={{ height: sizing.button.md }}> // 44
// Input heights
<TextInput style={{ height: sizing.input.md }} /> // 48
// Avatar sizes
<Image style={{ width: sizing.avatar.md, height: sizing.avatar.md }} /> // 40×40

Shadows

Shadows automatically match the current theme (lighter in light mode, deeper in dark mode):

const { shadows } = useTheme();
<AppView style={[styles.card, shadows.md]} />

Use spread syntax (...shadows.md) to merge shadow props into a StyleSheet object:

const styles = StyleSheet.create({
card: {
backgroundColor: colors.surface,
borderRadius: radius.lg,
padding: spacing['2xl'],
},
});
// Then at render time:
<AppView style={[styles.card, shadows.md]} />

LeveliOS feelAndroid elevationUse fornoneNo shadow0Flat elementssmVery subtle lift1Tags, chipsmdGentle card shadow3Cards, inputslgProminent float6Floating buttons, selected cardsxlDeep shadow10Modals, bottom sheets

11. Building a New Screen

Follow this template every time you create a new screen:

// src/app/my-new-screen.tsx  (or inside a feature folder)
import React from 'react';
import { StyleSheet } from 'react-native';
import { AppSafeView, AppScrollView, AppText, AppView } from '@/shared/components';
import { useTheme } from '@/hooks/useTheme';
export default function MyNewScreen() {
const { colors, spacing, radius, shadows } = useTheme();
return (
<AppSafeView>
<AppScrollView>
{/* Header */}
<AppView style={[styles.header, { paddingHorizontal: spacing.lg }]}>
<AppText variant="h2">Screen Title</AppText>
<AppText variant="body" color="textSecondary">
A short description of what this screen does.
</AppText>
</AppView>
{/* A card */}
<AppView
bg="surface"
style={[
styles.card,
{
margin: spacing.lg,
padding: spacing['2xl'],
borderRadius: radius.lg,
...shadows.md,
},
]}
>
<AppText variant="h4">Card Title</AppText>
<AppText variant="body" color="textSecondary" style={{ marginTop: spacing.xs }}>
Card content goes here.
</AppText>
</AppView>
</AppScrollView>
</AppSafeView>
);
}
// Only put values here that truly never change (layout ratios, flex)
// Never put colours, spacing values, or sizes here — those come from useTheme()
const styles = StyleSheet.create({
header: {
paddingTop: 32,
paddingBottom: 16,
},
card: {
// structural only
},
});

Key rules:

  • AppSafeView is always the root element of a screen.
  • useTheme() is called at the top of the component, not inside loops or conditions.
  • StyleSheet.create holds only layout values. Colour, spacing, and size values come from useTheme() and are applied inline.

12. Building a New Component

When building a component that will be reused across the app:

// src/shared/components/StatusBadge.tsx  (example)
import React from 'react';
import { StyleSheet, View } from 'react-native';
import { AppText } from './AppText';
import { useTheme } from '@/hooks/useTheme';
import type { ColorTokens } from '@/types/theme.types';
type BadgeVariant = 'success' | 'error' | 'warning' | 'info';interface StatusBadgeProps {
label: string;
variant?: BadgeVariant;
}
// Map variant to semantic colour token names
const BG_TOKEN: Record<BadgeVariant, keyof ColorTokens> = {
success: 'successBg',
error: 'errorBg',
warning: 'warningBg',
info: 'infoBg',
};
const TEXT_TOKEN: Record<BadgeVariant, keyof ColorTokens> = {
success: 'success',
error: 'error',
warning: 'warning',
info: 'info',
};
export function StatusBadge({ label, variant = 'info' }: StatusBadgeProps) {
const { colors, spacing, radius } = useTheme();
return (
<View
style={[
styles.base,
{
backgroundColor: colors[BG_TOKEN[variant]],
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
borderRadius: radius.full,
},
]}
>
<AppText variant="labelSm" color={TEXT_TOKEN[variant]} uppercase>
{label}
</AppText>
</View>
);
}
const styles = StyleSheet.create({
base: {
alignSelf: 'flex-start',
},
});

Then add it to the barrel export:

// src/shared/components/index.ts
export { StatusBadge } from './StatusBadge';
export type { StatusBadgeProps } from './StatusBadge';

13. How to Modify the Theme

Add a new colour token

  1. Add to the interface in src/types/theme.types.ts:
export interface ColorTokens {
// ... existing tokens ...
cardHeaderBg: string; // ← new token
}
  1. Assign a value in both themes:
// src/theme/light.ts
colors: {
// ...
cardHeaderBg: palette.rose100,
}
// src/theme/dark.ts
colors: {
// ...
cardHeaderBg: palette.neutral700,
}

TypeScript will give you a compile error if you forget to add it to either theme.

  1. Use it anywhere:
<AppView bg="cardHeaderBg" />
<AppText color="cardHeaderBg">…</AppText>

Add a new text variant

  1. Add the key to TextVariantKey in src/types/theme.types.ts:
export type TextVariantKey =
| 'display' | 'h1' | 'h2' | /* … */ | 'myNewVariant'; // ← add here
  1. Define the variant in src/theme/typography.ts:
export const textVariants: Record<TextVariantKey, TextVariant> = {
// ...
myNewVariant: {
fontStack: 'sans',
fontSize: fontSizes.lg,
fontWeight: fontWeights.medium,
lineHeight: Math.round(fontSizes.lg * lineHeights.normal),
},
};
  1. Use it:
<AppText variant="myNewVariant">Text</AppText>

Change an existing colour value

Open src/theme/light.ts or src/theme/dark.ts and change the palette reference. You should only be pointing to a value already in palette (from colors.ts). If the colour you need is not in the palette, add it there first.

// Before
primary: palette.rose400,
// After (if you want a richer primary)
primary: palette.rose500,

Add a new raw colour to the palette

Open src/theme/colors.ts and add the new colour to the appropriate group. Give it a meaningful name that describes the hue and shade level:

export const palette = {
// ...
rose450: '#BE9AA8', // ← add here
};

You can then use it in light.ts or dark.ts.

Change a spacing value

The spacing scale is intentionally fixed — it is a 4-point grid and the steps should not change. If you need a value between steps, talk to the team first. In most cases the right answer is to choose the nearest step.

If a completely new scale step is genuinely needed, add it to src/theme/spacing.ts and SpacingKey in theme.types.ts.

14. Adding Custom Fonts

The brand guide specifies Libre Baskerville (serif) and Dora (sans-serif). The system is already wired to accept them. These are the three steps to activate them:

Step 1 — Add the font files:

assets/
└── fonts/
├── LibreBaskerville-Regular.ttf
├── LibreBaskerville-Italic.ttf
├── LibreBaskerville-Bold.ttf
├── Dora-Regular.ttf
├── Dora-Medium.ttf
├── Dora-SemiBold.ttf
└── Dora-Bold.ttf

Step 2 — Uncomment the useFonts block in src/app/_layout.tsx:

import { useFonts } from 'expo-font';
import { customFontNames } from '@/theme';
const [fontsLoaded, fontError] = useFonts({
[customFontNames.serifRegular]: require('../../assets/fonts/LibreBaskerville-Regular.ttf'),
[customFontNames.serifItalic]: require('../../assets/fonts/LibreBaskerville-Italic.ttf'),
[customFontNames.serifBold]: require('../../assets/fonts/LibreBaskerville-Bold.ttf'),
[customFontNames.sansRegular]: require('../../assets/fonts/Dora-Regular.ttf'),
[customFontNames.sansMedium]: require('../../assets/fonts/Dora-Medium.ttf'),
[customFontNames.sansSemiBold]: require('../../assets/fonts/Dora-SemiBold.ttf'),
[customFontNames.sansBold]: require('../../assets/fonts/Dora-Bold.ttf'),
});
useEffect(() => {
if (fontsLoaded || fontError) SplashScreen.hideAsync();
}, [fontsLoaded, fontError]);
if (!fontsLoaded && !fontError) return null;

Step 3 — Update fontFamilies in src/theme/typography.ts:

import { customFontNames } from './typography'; // (already in the same file)
export const fontFamilies = {
serif: customFontNames.serifRegular,
serifBold: customFontNames.serifBold,
serifItalic: customFontNames.serifItalic,
sans: customFontNames.sansRegular,
sansMedium: customFontNames.sansMedium,
sansSemiBold: customFontNames.sansSemiBold,
sansBold: customFontNames.sansBold,
};

Every AppText in the app will immediately render with the real brand fonts — no other changes needed.

15. Best Practices

Do call useTheme() once per component, at the top level.

// Correct
function MyComponent() {
const { colors, spacing } = useTheme();
return <AppView style={{ padding: spacing.lg, backgroundColor: colors.surface }} />;
}

Do use semantic token names, not palette names.

// Correct — the name describes meaning, adapts to dark mode
color: colors.textSecondary
// Wrong — the name describes a colour, will not adapt
color: colors.neutral600 // (palette is not exposed to components anyway)

Do spread shadows into the style array.

// Correct
<AppView style={[styles.card, shadows.md]} />
// Wrong — shadow props are individual, not a sub-object
<AppView style={styles.card} shadowColor={shadows.md.shadowColor} … />

Do put only structural styles in StyleSheet.create.

// Correct — structure goes in StyleSheet, values come from the theme
const styles = StyleSheet.create({ card: { flex: 1, overflow: 'hidden' } });
<AppView style={[styles.card, { backgroundColor: colors.surface, padding: spacing.lg }]} />

Do use AppText for all text. Never render a raw <Text> in a screen or feature component.

Do use AppSafeView as the root of every screen. It handles the safe area and applies the correct background automatically.

Do use AppPressable for all touch targets. It provides the correct platform press feedback (Android ripple, iOS opacity) and includes a disabled prop.

16. Common Mistakes

Importing the palette directly in a component:

// Wrong — bypasses the semantic layer and breaks dark mode
import { palette } from '@/theme/colors';
<View style={{ backgroundColor: palette.rose400 }} />
// Correct
const { colors } = useTheme();
<AppView style={{ backgroundColor: colors.primary }} />

Hardcoding hex values:

// Wrong
<View style={{ borderColor: '#E8E0E3' }} />
// Correct
<AppView style={{ borderColor: colors.border }} />

Using arbitrary spacing numbers:

// Wrong — number chosen arbitrarily
<View style={{ padding: 13, marginTop: 7 }} />
// Correct — from the 4-point grid
<AppView style={{ padding: spacing.md, marginTop: spacing.xs }} />

Writing if (isDark) to choose colours:

// Wrong — this is exactly what semantic tokens are for
const bgColor = isDark ? '#221A1E' : '#FFFFFF';
<View style={{ backgroundColor: bgColor }} />
// Correct — the token resolves to the right value automatically
<AppView bg="background" />

Rendering raw <Text> from React Native:

// Wrong — does not consume the theme
import { Text } from 'react-native';
<Text style={{ fontSize: 16, color: '#160C12' }}>Hello</Text>
// Correct
<AppText variant="body">Hello</AppText>

Calling useTheme() conditionally or inside a nested function:

// Wrong — React hooks must be called unconditionally at the top level
function MyComponent({ show }: { show: boolean }) {
if (show) {
const { colors } = useTheme(); // ← violates the Rules of Hooks
}
}
// Correct
function MyComponent({ show }: { show: boolean }) {
const { colors } = useTheme(); // always at the top
if (!show) return null;
return <AppText color="textSecondary">Visible</AppText>;
}

Adding theme logic to StyleSheet.create:

// Wrong — StyleSheet.create runs once at module load time, before the theme is known
const styles = StyleSheet.create({
card: { backgroundColor: colors.surface }, // ← 'colors' is not available here
});
// Correct — theme values go in the component body, not StyleSheet.create
const styles = StyleSheet.create({ card: {} });
function Card() {
const { colors } = useTheme();
return <AppView style={[styles.card, { backgroundColor: colors.surface }]} />;
}

For questions about design decisions or to propose changes to the token set, discuss with the team before editing light.ts, dark.ts, or theme.types.ts — those files affect the entire app.

--

--