03 · Styling with StyleSheet¶
React Native styling uses JavaScript objects with CSS-like property names in camelCase. There are no
stylesheets, no selectors, no cascade and no specificity wars. Every component gets exactly the
styles you pass to its style prop — nothing more. Once you stop looking for CSS features that
aren't there, this turns out to be a very predictable system.
The basics¶
import { StyleSheet, Text, View } from 'react-native';
export function Badge({ label }: { label: string }) {
return (
<View style={styles.badge}>
<Text style={styles.text}>{label}</Text>
</View>
);
}
const styles = StyleSheet.create({
badge: {
backgroundColor: '#eef2ff',
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 999,
alignSelf: 'flex-start',
},
text: { color: '#4338ca', fontSize: 12, fontWeight: '600', letterSpacing: 0.3 },
});
Differences from CSS to internalise:
- Units: numbers are density-independent pixels (dp on Android, points on iOS).
fontSize: 16is the same physical size on a low- and a high-density screen. There is nopx,remorem. Percentages are allowed as strings for sizes and positions (width: '50%'). - Shorthands are partial:
paddingHorizontal/paddingVertical/marginHorizontalexist instead ofpadding: '4px 10px'.border: '1px solid'doesn't exist — useborderWidth,borderColor,borderStyle. fontWeightis a string ('400','600','bold') — or a number in newer versions; strings work everywhere.- No inheritance, except nested
Text(lesson 2). - No pseudo-classes or media queries —
:hover,:pressed, dark mode and breakpoints are all done in JavaScript with state and hooks.
Combining styles: arrays¶
The style prop accepts an array. Later entries win, and falsy entries are ignored, which makes
conditional styling clean:
<View style={[styles.card, selected && styles.cardSelected, { marginTop: index === 0 ? 0 : 12 }]} />
This is how you replace CSS modifiers like .card--selected. Inline objects (the last entry) are
fine for values that are truly dynamic; put static styles in StyleSheet.create.
What StyleSheet.create actually gives you¶
StyleSheet.create returns the same objects you passed in (in development it validates property
names and logs mistakes). Its value is mainly organisational and for tooling — typed autocompletion
and catching typos like backgroundColour. There is no hidden performance trick to rely on; inline
style objects work too. Use create because it keeps styles named, typed and out of your render
logic.
Other helpers:
StyleSheet.hairlineWidth; // thinnest visible line on this device's screen
StyleSheet.absoluteFill; // { position: 'absolute', left: 0, right: 0, top: 0, bottom: 0 }
StyleSheet.flatten([a, b]); // merge a style array into one object (useful in tests)
Shadows: the platform split¶
Shadows show the platforms' different heritage. Recent React Native versions support the
cross-platform boxShadow property (with the New Architecture), using CSS syntax:
const styles = StyleSheet.create({
card: {
backgroundColor: 'white',
borderRadius: 16,
boxShadow: '0 2px 8px rgba(15, 23, 42, 0.12)',
},
});
Older code you'll read uses the per-platform properties: shadowColor, shadowOffset,
shadowOpacity, shadowRadius on iOS, and elevation on Android. A shadow needs a
background colour on the view to render correctly on both platforms; a transparent view with a
shadow often shows nothing (Android) or shadows every child separately (iOS).
Design tokens instead of a stylesheet language¶
Without Sass variables, put your design decisions in plain TypeScript:
export const colors = {
light: { bg: '#f8fafc', surface: '#ffffff', text: '#0f172a', muted: '#64748b', primary: '#4f46e5' },
dark: { bg: '#020617', surface: '#0f172a', text: '#f1f5f9', muted: '#94a3b8', primary: '#818cf8' },
} as const;
export const space = { xs: 4, sm: 8, md: 12, lg: 16, xl: 24 } as const;
export const radius = { sm: 8, md: 12, lg: 16, pill: 999 } as const;
export const font = { body: 16, small: 13, title: 22 } as const;
// Both palettes share keys; widen the literal colour values to string so either fits.
export type Palette = Record<keyof (typeof colors)['light'], string>;
Worked example: dark mode and an AppText component¶
useColorScheme() returns 'light', 'dark' or null, and re-renders when the user switches
system theme. Build one small hook and one text component, and most of your styling problems go
away:
import { useColorScheme } from 'react-native';
import { colors, Palette } from './theme';
export function useTheme(): Palette {
return useColorScheme() === 'dark' ? colors.dark : colors.light;
}
import { Text, TextProps } from 'react-native';
import { useTheme } from './useTheme';
import { font } from './theme';
type Variant = 'body' | 'title' | 'muted';
export function AppText({ variant = 'body', style, ...rest }: TextProps & { variant?: Variant }) {
const c = useTheme();
const variantStyle = {
body: { fontSize: font.body, color: c.text },
title: { fontSize: font.title, color: c.text, fontWeight: '700' as const },
muted: { fontSize: font.small, color: c.muted },
}[variant];
return <Text {...rest} style={[variantStyle, style]} />;
}
Note the order [variantStyle, style]: the caller's style comes last so callers can always
override. Then in app.json, set "userInterfaceStyle": "automatic" — the blank template ships
with "light", which forces light mode and makes useColorScheme() always return 'light'.
function HabitRow({ name, done }: { name: string; done: boolean }) {
const c = useTheme();
return (
<View style={[rowStyles.row, { backgroundColor: c.surface }]}>
<AppText style={done && rowStyles.done}>{name}</AppText>
<AppText variant="muted">{done ? 'Done' : 'Pending'}</AppText>
</View>
);
}
const rowStyles = StyleSheet.create({
row: { flexDirection: 'row', justifyContent: 'space-between', padding: 16, borderRadius: 12 },
done: { textDecorationLine: 'line-through', opacity: 0.6 },
});
How It Actually Works¶
Your style objects travel with the component's props to Fabric. Fabric splits each style into two
groups: layout props (width, flex, padding, margin, position, …), which are handed to
Yoga to compute geometry, and view props (backgroundColor, borderRadius, opacity,
transform, …), which are applied to the native view. When you pass an array, React Native
flattens it left to right into one object before diffing, which is why later entries win and why
false/null/undefined entries vanish harmlessly.
Because there is no cascade, the renderer never has to work out which rules apply to which element —
each node's style is exactly its own flattened object. That is the trade: you write a little more
(passing typography down explicitly, or wrapping it in AppText), and in exchange there's no
action-at-a-distance. A style change in one file can only affect the components that import it.
Density-independent units are converted at the native edge: on a 3× iPhone, width: 100 becomes
300 physical pixels. PixelRatio.get() exposes that scale if you ever need exact physical pixels
(for example, requesting a correctly sized image from a CDN).
Common mistakes¶
- Writing CSS strings like
padding: '8px 16px'orborder: '1px solid #ccc'— not supported. - Expecting
coloron aViewto style the text inside. - Hard-coding colours in every component, then discovering dark mode means touching 80 files. Tokens from day one.
- Leaving
userInterfaceStyle: "light"inapp.jsonand wondering why dark mode never triggers. - Putting the caller's
stylefirst in a reusable component's array — the component's own styles then silently override what the caller asked for.
Exercise¶
- Create
theme.ts,useTheme.tsandAppText.tsxas above. - Build a
Cardcomponent that takestitle,subtitleand an optionalhighlightedboolean, uses the theme surface colour, and applies a border in the primary colour when highlighted. - Switch your phone between light and dark mode and confirm everything updates without reloading.
- Add a
Buttoncomponent withvariant: 'primary' | 'secondary'and adisabledstate that lowers opacity — keeping the caller-override order correct.