ReadyNative
UI

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:

src/screens/profile/profile-screen.tsx
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:

PrimitivePurpose
ThemeProvider, useTheme()Resolved mode + colors / space / radius for the current theme
ScreenSafe-area aware page (scroll, padded, safe: "top" | "bottom" | "both" | "none", bg); scroll keeps the focused input above the keyboard (keyboard-controller)
BoxLayout: p/px/py/pt/pb/pl/pr, m/…, gap, bg, row, align, justify, flex, radius, border
Textvariant (body, title, heading, subtitle, caption, label, code), color, weight, align
Buttonvariant (primary, secondary, outline, ghost, destructive, link), size, loading, icons
PressableTap target with optional haptic and pressed-state scale, animated on the UI thread by Pressto
CardBordered surface, optionally pressable
RowOne settings-style line: leading icon/node, title/subtitle, trailing or chevron, destructive
ListVirtualised list of rows: data, renderItem, separator, header/footer, empty, onRefresh, inset
Input, SwitchForm controls with label / error
Divider, Icon, Skeleton, LoadingChrome; Icon takes an SF Symbol name plus a Material fallback
EmptyState, ErrorStateFull-screen states with an optional action / retry
SheetNative 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 so Button, Card and pressable Rows) 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 a KeyboardAwareScrollView: the focused field scrolls into view above the keyboard, and dragging the content dismisses it interactively, the way iOS apps do.
  • Sheet is 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.

src/screens/history/history-screen.tsx
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 wantProp / token
Spacingp, 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)
Colourbg, color take background, foreground, card, primary, muted, mutedForeground, destructive, destructiveForeground, success, border, brandAccent, …
Radiusradius takes sm, md, lg, xl, full - derived from brand.radius
Text stylevariant takes title, heading, subtitle, body, caption, label, code; weight takes normal … extrabold
Button lookvariant 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 setValues
colors.light / .darkbackground, foreground, card, cardForeground, popover, popoverForeground, primary, primaryForeground, secondary, secondaryForeground, muted, mutedForeground, accent, accentForeground, destructive, destructiveForeground, success, border, input, ring, brandAccent
space0:0, 1:4, 2:8, 3:12, 4:16, 5:20, 6:24, 8:32, 10:40, 12:48, 16:64 (px, Tailwind steps)
radiussm, md, lg, xl, full - derived from brand.radius (= lg; sm = lg - 4, md = lg - 2, xl = lg + 4)
fontSize / lineHeightxs 12/16, sm 14/20, base 16/24, lg 18/28, xl 20/28, 2xl 24/32, 3xl 30/36, 4xl 36/40
fontWeightnormal 400, medium 500, semibold 600, bold 700, extrabold 800
fontFamilybrand.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):

readynative.config.ts
brand: { primary: "#7c3aed", accent: "#f59e0b", radius: 16, font: "Inter" },
bun run gen:theme

primary 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:

StackGenerated file(s)
nativewind5src/global.css
nativewind4src/global.css + tailwind.config.js (HSL triples)
tamaguitamagui.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" - persisted

For 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:

src/components/badge.tsx
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:

MistakeDo this instead
style={{ padding: 16 }}p={4}
style={{ color: "#737373" }}color="mutedForeground"
Importing View / Text from react-native in a screenBox / 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.tsEdit src/theme/tokens.ts, run bun run gen:theme
Checking useColorScheme() yourselfuseResolvedMode() - it respects the stored preference

Where to go next

On this page

Get ReadyNative