Styling
Build a screen from the @/components/ui primitives, use tokens instead of hex and pixels, and get dark mode for free.
Hey - here's how I style screens in ReadyNative. By the end you'll know the one pattern every screen uses, the tokens behind it, and how to write your own component in your stack. Dark mode isn't a step: every colour token already has a light and a dark value.
Pick your UI option in the Your stack panel and the snippets below change with it.
The pattern
Screens compose primitives from @/components/ui and pass tokens, not values:
import { Box, Button, Card, Screen, Text } from "@/components/ui";
export function ProfileScreen() {
return (
<Screen scroll>
<Box gap={4}>
<Text variant="title">Profile</Text>
<Card p={4}>
<Text color="mutedForeground">Signed in as ada@example.com</Text>
</Card>
<Button onPress={signOut} variant="outline">
Sign out
</Button>
</Box>
</Screen>
);
}gap={4} is 16px, p={4} is 16px, mutedForeground is grey in light and lighter grey in
dark. That's the whole idea: you never type a number or a hex string in a screen.
The snippets here are for the finalized tree bun run setup leaves you, with the kit in
src/components/ui. Before setup, or after setup --keep-modules, it's in src/ui and the
import is @/ui; everything else on this page is identical.
Never hardcode a colour or a spacing
style={{ padding: 16, color: "#000" }} looks right today and breaks the moment someone flips
dark mode or you change brand.primary. Reach for a token prop first, useTheme() second,
style last.
The primitives
The kit exports the same primitives on every UI stack:
| Primitive | Purpose |
|---|---|
ThemeProvider, useTheme() | Resolved mode + colors / space / radius for the current theme |
Screen | Safe-area aware page (scroll, padded, safe: "top" | "bottom" | "both" | "none", bg); scroll keeps the focused input above the keyboard (keyboard-controller) |
Box | Layout: p/px/py/pt/pb/pl/pr, m/…, gap, bg, row, align, justify, flex, radius, border |
Text | variant (body, title, heading, subtitle, caption, label, code), color, weight, align |
Button | variant (primary, secondary, outline, ghost, destructive, link), size, loading, icons |
Pressable | Tap target with optional haptic and pressed-state scale, animated on the UI thread by Pressto |
Card | Bordered surface, optionally pressable |
Row | One settings-style line: leading icon/node, title/subtitle, trailing or chevron, destructive |
List | Virtualised list of rows: data, renderItem, separator, header/footer, empty, onRefresh, inset |
Input, Switch | Form controls with label / error |
Divider, Icon, Skeleton, Loading | Chrome; Icon takes an SF Symbol name plus a Material fallback |
EmptyState, ErrorState | Full-screen states with an optional action / retry |
Sheet | Native bottom sheet (visible, onClose, detents: ["half", "full"]): SwiftUI / Material 3 via @expo/ui, works in Expo Go |
ToastHost, toast.show() | toast.show({ title, kind, duration }) anywhere: a native iOS toast (Burnt) in dev and store builds, the themed card from ToastHost in Expo Go, on Android and web |
Their prop types live in src/components/ui/types.ts. Theme mode (useThemeMode,
setThemeMode, useResolvedMode) is exported from the kit too, so screens never touch the store
directly - see Dark mode.
Box's align and justify take the flexbox names and short aliases: start, center,
end, between, around, evenly, stretch, baseline. An alias that means nothing on its
axis (between on align, baseline on justify) falls back to that axis' default.
Native by default
The primitives reach for the platform wherever it has something better than a drawn view:
toast.show()is Burnt's system toast on iOS (with haptics, above native modals); the kit's own card renders in Expo Go, on Android and on web, so screens never branch.Pressable(and soButton,Cardand pressableRows) runs its press on a gesture-handler button with the scale animated on the UI thread - it tracks the finger while JS is busy and cancels cleanly when a list scrolls.<Screen scroll>is aKeyboardAwareScrollView: the focused field scrolls into view above the keyboard, and dragging the content dismisses it interactively, the way iOS apps do.Sheetis a real native sheet: detents, the grabber, Liquid Glass on iOS 26.
The root layout mounts GestureHandlerRootView and KeyboardProvider once for all of it. The
tab bar is NativeTabs - see Your first feature.
const [open, setOpen] = useState(false);
<Button onPress={() => setOpen(true)}>Details</Button>
<Sheet visible={open} onClose={() => setOpen(false)} detents={["half", "full"]}>
<Text variant="heading">Details</Text>
</Sheet>List screens
A list screen is a non-scroll <Screen> plus a <List>: Screen owns the safe-area
insets and List scrolls inside them, so neither fights the other over the bottom inset.
List is virtualised (RN FlatList) and sets contentInsetAdjustmentBehavior="automatic"
on iOS; nesting one inside <Screen scroll> gives up virtualisation and is only for a
handful of static rows. A Row draws no divider - List (or a Divider between Rows in a
Card) owns the separators.
export function HistoryScreen() {
const { data = [] } = useHistory();
return (
<Screen padded={false}>
<List
data={data}
empty={<EmptyState title="Nothing yet" />}
renderItem={(h) => (
<Row title={h.title} subtitle={h.at} leading="clock" onPress={() => open(h.id)} />
)}
/>
</Screen>
);
}Tokens
Every prop on a primitive takes a token from src/theme/tokens.ts:
| You want | Prop / token |
|---|---|
| Spacing | p, px, m, gap, … take 0 1 2 3 4 5 6 8 10 12 16 (= 0 4 8 12 16 20 24 32 40 48 64 px) |
| Colour | bg, color take background, foreground, card, primary, muted, mutedForeground, destructive, destructiveForeground, success, border, brandAccent, … |
| Radius | radius takes sm, md, lg, xl, full - derived from brand.radius |
| Text style | variant takes title, heading, subtitle, body, caption, label, code; weight takes normal … extrabold |
| Button look | variant takes primary, secondary, outline, ghost, destructive, link; size takes sm, md, lg, icon |
The colour names are shadcn's, so anything you copy from that ecosystem maps one to one.
style is the only escape hatch: every primitive accepts it, and there's deliberately no
className on the kit (why).
Theme tokens and gen:theme
src/theme/tokens.ts is the design-token source of truth. It's plain data importable from
Node (unit tests, scripts/gen-theme.ts) and reads brand from readynative.config.ts:
| Token set | Values |
|---|---|
colors.light / .dark | background, foreground, card, cardForeground, popover, popoverForeground, primary, primaryForeground, secondary, secondaryForeground, muted, mutedForeground, accent, accentForeground, destructive, destructiveForeground, success, border, input, ring, brandAccent |
space | 0:0, 1:4, 2:8, 3:12, 4:16, 5:20, 6:24, 8:32, 10:40, 12:48, 16:64 (px, Tailwind steps) |
radius | sm, md, lg, xl, full - derived from brand.radius (= lg; sm = lg - 4, md = lg - 2, xl = lg + 4) |
fontSize / lineHeight | xs 12/16, sm 14/20, base 16/24, lg 18/28, xl 20/28, 2xl 24/32, 3xl 30/36, 4xl 36/40 |
fontWeight | normal 400, medium 500, semibold 600, bold 700, extrabold 800 |
fontFamily | brand.font - a name only; load the file yourself (Config → Brand font) |
To change the brand, edit brand in readynative.config.ts and regenerate
(Make it yours walks through it, dark mode included):
brand: { primary: "#7c3aed", accent: "#f59e0b", radius: 16, font: "Inter" },bun run gen:themeprimary overrides the light-mode primary colour, accent is exposed as brandAccent in both
modes, and radius is lg. For the full palette, edit src/theme/tokens.ts directly: it's
plain data with a light and a dark object. cssColorVariables(mode) returns the --color-*
map NativeWind consumes.
bun run gen:theme renders the tokens into your stack's theme file. StyleSheet and Unistyles
read tokens.ts directly, so they have nothing to generate:
| Stack | Generated file(s) |
|---|---|
nativewind5 | src/global.css |
nativewind4 | src/global.css + tailwind.config.js (HSL triples) |
tamagui | tamagui.config.ts (static values, no @/ imports) |
It writes the active stack's file (from .readynative.json) at the root and, while modules/
exists, every stack's copy under modules/ui/<stack>/files/. bun run gen:theme --check exits
1 on drift, so you can gate CI on it. Edit tokens, never the generated files.
Dark mode
There's nothing to do. Every colour token resolves per mode, so a screen written with tokens is already correct in both. The user's preference lives in Settings → Appearance, and you can read or set it from anywhere:
import { setThemeMode, useResolvedMode } from "@/components/ui";
const mode = useResolvedMode(); // "light" | "dark" - what's on screen right now
setThemeMode("dark"); // "system" | "light" | "dark" - persistedFor the rare inline style that needs a real colour, useTheme() gives you the resolved
palette:
import { useTheme } from "@/components/ui";
const { colors, space, radius } = useTheme();
<MapView style={{ borderRadius: radius.lg, borderColor: colors.border }} />;Your own components
When a screen needs something the kit doesn't have, put it in src/components/ and style it
the way your stack does.
Tailwind classes, with the token names as colours:
import { Text, View } from "react-native";
export function Badge({ children }: { children: string }) {
return (
<View className="self-start rounded-full bg-primary px-3 py-1">
<Text className="text-xs font-medium text-primary-foreground">{children}</Text>
</View>
);
}bg-primary, text-muted-foreground, border-border and friends come from src/global.css,
which bun run gen:theme writes from the tokens - don't edit the CSS. Class names can't be
built at runtime (p-${n} never compiles), so read useTheme() for anything dynamic.
See every primitive
The app ships a catalog route with every primitive, every variant and both modes side by side:
Settings → UI catalog, or open /_catalog. It's demo content, so setup removes it
unless you pass --with-examples.
Common mistakes
Each of these looks fine at first and costs you later. Here's what to write instead:
| Mistake | Do this instead |
|---|---|
style={{ padding: 16 }} | p={4} |
style={{ color: "#737373" }} | color="mutedForeground" |
Importing View / Text from react-native in a screen | Box / Text from @/components/ui - they carry the tokens and dark mode |
data.map() inside <Screen scroll> | <Screen> + <List> - virtualised, and the safe-area insets don't fight |
Editing src/global.css / tamagui.config.ts | Edit src/theme/tokens.ts, run bun run gen:theme |
Checking useColorScheme() yourself | useResolvedMode() - it respects the stored preference |
Where to go next
- UI contract - the adapter contract behind the kit, for
--keep-modulestrees - Your UI option - setup, usage and gotchas for the stack you picked
- Add your first feature - a whole tab built from these primitives
Your first update
Push a JavaScript-only fix to installed apps with EAS Update, watch it arrive, and roll it back if you need to.
No ios/ or android/ folders
How ReadyNative keeps native projects generated instead of committed - where native config lives, how to add a native library, how SDK upgrades stay a version bump, and how that pairs with over-the-air updates.