Stop Hardcoding Colors: PlatformColor Makes React Native Apps Feel Native

By amillionmonkeys
#React Native#Expo#Mobile Development#Dark Mode#UI/UX

How PlatformColor solves dark mode nightmares and makes your React Native app feel truly native with iOS and Android semantic colors.

I discovered PlatformColor while rebuilding the Pinx app, and honestly, I wish I'd known about it years ago. Here's the problem: you hardcode #FF0000 for a nice red accent color, ship your app, and then someone turns on dark mode and your beautiful red is either invisible against a dark background or blindingly aggressive.

Worse, your app looks foreign on both platforms. iOS users expect that soft, adaptive systemRed. Android users expect Material Design theme colors. Instead, they're getting your hardcoded hex values that don't respect their system preferences.

PlatformColor fixes this. It's a React Native API that lets you tap directly into native platform colors—the same semantic colors that Apple and Google use in their own apps. And they automatically adapt to light mode, dark mode, high contrast, and whatever else the user throws at you.

What PlatformColor Actually Does

PlatformColor is deceptively simple. Instead of this:

<Text style={{ color: '#FF0000' }}>Error message</Text>

You write this:

import { Platform, PlatformColor } from 'react-native';
 
<Text style={{
  color: PlatformColor(
    Platform.OS === 'android' ? '@android:color/holo_red_dark' : 'systemRedColor'
  )
}}>
  Error message
</Text>

Now your text uses iOS's systemRedColor on iPhone and Android's holo_red_dark on Android. Both automatically adapt to the user's theme settings. When they switch to dark mode, the colors adjust—lighter on dark backgrounds, darker on light backgrounds—without you writing a single line of theme-switching logic.

Real Implementation: Native Tab Bars

Here's actual code from the Pinx rebuild where we used PlatformColor for native tab bar icons:

import { Platform, PlatformColor } from 'react-native';
 
<NativeTabs
  iconColor={PlatformColor(
    Platform.OS === 'android'
      ? '@android:color/holo_red_dark'
      : 'systemRedColor'
  )}
  tintColor={PlatformColor(
    Platform.OS === 'android'
      ? '@android:color/holo_red_dark'
      : 'systemRedColor'
  )}
>
  {/* tabs content */}
</NativeTabs>

This gives us a consistent red accent across platforms, but each platform gets its red—not some compromise middle ground. iOS users see systemRedColor (which is actually #FF3B30 in light mode but adjusts in dark mode). Android users see holo_red_dark (#CC0000). Both feel native because they are native.

The Comprehensive Color Reference

This is the table I wish existed when I started. It maps iOS semantic colors to their closest Android equivalents and explains what each is actually for.

System Colors (Brand/Accent Colors)

iOS ColorAndroid EquivalentPurpose
systemRed@android:color/holo_red_dark or ?attr/colorErrorDestructive actions, errors
systemBlue?attr/colorPrimaryPrimary actions, links, selection
systemGreen@android:color/holo_green_darkSuccess states, confirmation
systemOrange@android:color/holo_orange_darkWarnings, secondary alerts
systemYellow@android:color/holo_orange_lightCaution, highlights
systemPink?attr/colorSecondaryAccent, creative actions
systemPurple@android:color/holo_purpleCreative content, special features
systemTeal@android:color/holo_blue_darkInformation, secondary branding
systemIndigo?attr/colorPrimaryVariantDeep actions, premium features

Gray Scale (Neutral Tones)

iOS ColorAndroid EquivalentPurpose
systemGray@android:color/darker_grayDisabled state, subtle elements
systemGray2?attr/colorControlNormalSecondary UI elements
systemGray3?attr/colorControlHighlightSubtle separators
systemGray4@android:color/lighter_grayBackground tints
systemGray5?attr/colorSurfaceCard backgrounds
systemGray6?attr/colorBackgroundDeepest gray before background

Text/Label Colors

iOS ColorAndroid EquivalentPurpose
label?attr/colorOnBackgroundPrimary text, body copy
secondaryLabel?attr/colorOnSurface (60% opacity)Subheadings, secondary info
tertiaryLabel?attr/colorOnSurface (38% opacity)Tertiary text, placeholders
quaternaryLabel?attr/colorOnSurface (12% opacity)Disabled text, watermarks
placeholderText?android:attr/textColorHintInput placeholders

Background Colors (Standard Stack)

iOS ColorAndroid EquivalentPurpose
systemBackground?attr/colorBackgroundPrimary background (white/black)
secondarySystemBackground?attr/colorSurfaceCards, modals, raised content
tertiarySystemBackground?attr/colorPrimarySurfaceGrouped content backgrounds

Background Colors (Grouped Stack)

iOS ColorAndroid EquivalentPurpose
systemGroupedBackground?attr/colorBackgroundTable grouped style background
secondarySystemGroupedBackground?attr/colorSurfaceGrouped content cells
tertiarySystemGroupedBackground?attr/colorPrimarySurfaceNested grouped content

Fill Colors (Semi-Transparent Overlays)

iOS ColorAndroid EquivalentPurpose
systemFill?attr/colorControlHighlightButton backgrounds (filled style)
secondarySystemFill?attr/colorControlActivated (lower opacity)Secondary button fills
tertiarySystemFill?attr/colorSurface (subtle tint)Tertiary fills
quaternarySystemFill?attr/colorSurface (very subtle)Barely-there fills

UI Elements

iOS ColorAndroid EquivalentPurpose
separator?android:attr/dividerVerticalDividers, hairlines (translucent)
opaqueSeparator?android:attr/listDividerSolid dividers (non-transparent)
link?attr/colorPrimaryHyperlinks, tappable text

Android-Specific Theme Attributes

These don't have direct iOS equivalents but are crucial for Android:

Android AttributePurpose
?attr/colorPrimaryPrimary brand color
?attr/colorPrimaryVariantDarker/lighter primary variant
?attr/colorSecondarySecondary brand accent
?attr/colorAccentLegacy accent (pre-Material 3)
?attr/colorControlActivatedActive checkboxes, switches
?attr/colorControlNormalInactive controls

Platform Differences: Android Prefixes Matter

Android has two color systems, and the prefix matters:

@android:color/ - System colors (fixed values)

PlatformColor('@android:color/holo_red_dark') // Always #CC0000

?attr/ - Theme attributes (app-controlled)

PlatformColor('?attr/colorPrimary') // Whatever your app theme defines

Use ?attr/ for colors that should match your app's branding. Use @android:color/ when you want a specific Android system color.

iOS doesn't have this distinction—all colors are semantic and adaptive.

Practical Pattern: Platform.select()

For cleaner code, use Platform.select():

const styles = StyleSheet.create({
  errorText: {
    color: Platform.select({
      ios: PlatformColor('systemRedColor'),
      android: PlatformColor('?attr/colorError'),
      default: '#FF0000', // Web fallback
    }),
  },
  primaryButton: {
    backgroundColor: Platform.select({
      ios: PlatformColor('systemBlue'),
      android: PlatformColor('?attr/colorPrimary'),
      default: '#007AFF',
    }),
  },
  bodyText: {
    color: Platform.select({
      ios: PlatformColor('label'),
      android: PlatformColor('?attr/colorOnBackground'),
      default: '#000000',
    }),
  },
});

This reads better than ternaries everywhere and handles web fallbacks gracefully.

Gotchas from Real Usage

1. Styled Components and NativeWind don't support PlatformColor

If you're using a CSS-in-JS library, PlatformColor might not work. We hit this in Pinx—had to use standard StyleSheet for components that needed PlatformColor.

2. Test in BOTH light and dark mode

Obvious but easy to forget. What looks great in light mode might be invisible in dark mode. Use iOS simulator appearance toggle and Android emulator theme settings constantly.

3. Fallbacks are critical

Always provide a fallback color:

color: Platform.OS === 'web'
  ? '#FF0000'
  : PlatformColor('systemRedColor')

React Native Web doesn't support PlatformColor, so you need explicit fallbacks or your web build breaks.

4. Not all libraries pass PlatformColor correctly

Some third-party components expect strings or hex values. If you pass PlatformColor and see errors, that's probably why. You might need to fork the component or submit a PR.

DynamicColorIOS: Advanced iOS Control

For more granular control on iOS, there's DynamicColorIOS:

import { DynamicColorIOS } from 'react-native';
 
const dynamicRed = DynamicColorIOS({
  light: '#FF3B30',
  dark: '#FF453A',
  highContrastLight: '#D70015',
  highContrastDark: '#FF6961',
});

This lets you specify exact values for each appearance mode. We haven't needed it yet—semantic colors handle 95% of cases—but it's there if you need pixel-perfect control.

When to Use PlatformColor

USE IT FOR:

  • UI chrome (tab bars, nav bars, toolbars)
  • Text colors (body, headings, secondary)
  • Backgrounds (screens, cards, modals)
  • System UI elements (separators, fills)
  • Destructive/success/warning states

DON'T USE IT FOR:

  • Brand colors that MUST be exact (your logo red)
  • Custom illustrations or graphics
  • Colors that are part of your visual identity
  • When supporting web (no PlatformColor support)

Why This Matters

Using PlatformColor isn't just about dark mode (though that's huge). It's about respecting user preferences. High contrast mode. Increased contrast. Color blindness accommodations. All of those work because you're using semantic colors the OS knows how to adjust.

Plus, your app feels native. Users might not consciously notice, but when your reds match Safari's reds and your backgrounds match Settings' backgrounds, the app feels like it belongs on their device.

Best Practices We've Learned

  1. Start with PlatformColor from day one - Retrofitting is painful
  2. Use the table above as a reference - Keep it handy when styling
  3. Create a colors constant file - Centralize your PlatformColor definitions
  4. Test appearance changes live - Toggle dark mode while the app is running
  5. Fallback to web-safe colors - Always have a plan for non-native platforms
  6. Document your color choices - Future you will forget why you chose systemGray3 over systemGray4

Our Color Constants Pattern

Here's how we organize PlatformColors in Pinx:

// colors.ts
import { Platform, PlatformColor } from 'react-native';
 
export const Colors = {
  text: {
    primary: Platform.select({
      ios: PlatformColor('label'),
      android: PlatformColor('?attr/colorOnBackground'),
      default: '#000000',
    }),
    secondary: Platform.select({
      ios: PlatformColor('secondaryLabel'),
      android: PlatformColor('?attr/colorOnSurface'),
      default: '#666666',
    }),
  },
  background: {
    primary: Platform.select({
      ios: PlatformColor('systemBackground'),
      android: PlatformColor('?attr/colorBackground'),
      default: '#FFFFFF',
    }),
    secondary: Platform.select({
      ios: PlatformColor('secondarySystemBackground'),
      android: PlatformColor('?attr/colorSurface'),
      default: '#F2F2F7',
    }),
  },
  accent: {
    primary: Platform.select({
      ios: PlatformColor('systemBlue'),
      android: PlatformColor('?attr/colorPrimary'),
      default: '#007AFF',
    }),
    destructive: Platform.select({
      ios: PlatformColor('systemRed'),
      android: PlatformColor('?attr/colorError'),
      default: '#FF3B30',
    }),
  },
};

Then import and use: color: Colors.text.primary. Clean, consistent, and easy to update.

The Bottom Line

PlatformColor turned dark mode from a nightmare into a non-issue. We don't think about it anymore. Define your colors once with semantic platform colors, and they just work across appearances, platforms, and accessibility settings.

The comprehensive table above should save you hours of digging through Apple and Android docs. Bookmark it, reference it, and your apps will feel more native with way less effort.

Building a React Native app that needs to feel truly native? We've now shipped several apps using PlatformColor and learned all the edge cases. If you'd like help building something that respects platform conventions while maintaining your brand, get in touch.

T: 07512 944360 | E: [email protected]

© 2025 amillionmonkeys ltd. All rights reserved.