Styling and theming the Expo app | Expo Supabase SaaS Kit
Restyle the native app to your brand with Uniwind's CSS-first theme tokens, wire up light/dark/system, and add UI components to @kit/mobile-ui without losing them on the next sync.
The Expo app is styled with Tailwind utility classes, the same way apps/web is. This page covers the one file you edit to rebrand it, how the light/dark/system preference is stored and applied, what @kit/mobile-ui gives you, and where a new component belongs so a component-library resync does not delete it.
The model: Tailwind v4 without a config file
Styling goes through Uniwind, which compiles Tailwind v4 for React Native. You write className on React Native components and Uniwind resolves the utilities into RN styles at build time through its Metro transformer.
Two things differ from what you know from the web app:
apps/web | apps/native | |
|---|---|---|
| Engine | Tailwind v4 via PostCSS | Tailwind v4 via Uniwind's Metro transformer |
| Config | CSS-first (@theme), no tailwind.config.js | CSS-first (@theme / @variant), no tailwind.config.js |
| Where classes land | real CSS in the browser | RN style objects — there is no CSS box model, no cascade, no :hover on device |
| Theme switch | .dark class on the document | Uniwind.setTheme(), a runtime API |
Because there is no cascade, only utilities Uniwind can map to an RN style property do anything. A class that has no RN equivalent is resolved and then dropped silently, with no build warning. This is the single most common surprise when porting a web component by hand.
Installed versions, from pnpm-workspace.yaml (the expo named catalog) and the resolved lockfile:
| Package | Version |
|---|---|
uniwind | ^1.10.0 (installed 1.10.0) |
tailwindcss | 4.3.3 |
lucide-react-native | 1.27.0 |
@react-native-reusables/cli | ^0.7.1 |
Neither apps/native nor packages/mobile-ui has one, and adding one does not do what you want — Uniwind reads its theme from CSS. Every theme change goes in apps/native/global.css.
apps/native/global.css — the whole theme
This is the Tailwind + Uniwind entrypoint. It is registered with Metro in apps/native/metro.config.js:
const config = withUniwindConfig(getDefaultConfig(__dirname), { cssEntryFile: './global.css', dtsFile: './uniwind-types.d.ts',});and imported exactly once, as the first line of apps/native/app/_layout.tsx:
import '../global.css';Do not import it anywhere else, and do not remove either half of metro.config.js — the withUniwindConfig wrapper must stay outermost, and the resolver block under it pins a single copy of React so @kit/* packages cannot drag a second one into the bundle.
Scanning mobile-ui's classes
@source '../../packages/mobile-ui/src/**/*.{ts,tsx}';Tailwind's automatic source detection is rooted at the directory containing the CSS entry file — Uniwind passes base: path.dirname(cssPath) when it compiles, which is apps/native/. Class names that appear only inside packages/mobile-ui/src are therefore invisible to the compiler, and every utility used by a Button, Card or Input would be stripped from the generated stylesheet. The @source directive adds that tree back.
If you add another workspace package that ships className strings, it needs its own @source line here.
The tokens
Uniwind's bundler recognises @variant <theme> rules, and it requires every registered theme to declare the same variable set. So the palette lives in two mirrored blocks, @variant light and @variant dark, and non-themed tokens live in the @theme block above them.
| Token | Light | Dark |
|---|---|---|
--color-background | hsl(0 0% 100%) | hsl(240 10% 3.9%) |
--color-foreground | hsl(240 10% 3.9%) | hsl(0 0% 98%) |
--color-card | hsl(0 0% 100%) | hsl(240 10% 3.9%) |
--color-card-foreground | hsl(240 10% 3.9%) | hsl(0 0% 98%) |
--color-popover | hsl(0 0% 100%) | hsl(240 10% 3.9%) |
--color-popover-foreground | hsl(240 10% 3.9%) | hsl(0 0% 98%) |
--color-primary | hsl(240 5.9% 10%) | hsl(0 0% 98%) |
--color-primary-foreground | hsl(0 0% 98%) | hsl(240 5.9% 10%) |
--color-secondary | hsl(240 4.8% 95.9%) | hsl(240 3.7% 15.9%) |
--color-secondary-foreground | hsl(240 5.9% 10%) | hsl(0 0% 98%) |
--color-muted | hsl(240 4.8% 95.9%) | hsl(240 3.7% 15.9%) |
--color-muted-foreground | hsl(240 3.8% 46.1%) | hsl(240 5% 64.9%) |
--color-accent | hsl(240 4.8% 95.9%) | hsl(240 3.7% 15.9%) |
--color-accent-foreground | hsl(240 5.9% 10%) | hsl(0 0% 98%) |
--color-destructive | hsl(359 74.7% 50.4%) | hsl(359 100% 69.6%) |
--color-destructive-foreground | hsl(0 0% 98%) | hsl(0 0% 98%) |
--color-success | hsl(142 76% 36%) | hsl(142 77% 73%) |
--color-border | hsl(240 5.9% 90%) | hsl(240 3.7% 15.9%) |
--color-input | hsl(240 5.9% 90%) | hsl(240 3.7% 15.9%) |
--color-ring | hsl(240 5.9% 10%) | hsl(240 4.9% 83.9%) |
Plus one non-themed token in the @theme block: --radius: 0.5rem.
Each --color-<name> token generates the usual utility families — bg-<name>, text-<name>, border-<name> — so bg-background, text-muted-foreground and border-border behave exactly as on web.
Rebranding
Editing the palette is the whole job:
- Open
apps/native/global.css. - Change the values inside
@variant light. Keep every declared token — Uniwind rejects a theme that declares a different variable set from its sibling. - Change the matching values inside
@variant dark. - Adjust
--radiusin the@themeblock if your brand is rounder or squarer. - Restart Metro. The stylesheet is generated at bundle time; a running server picks up CSS edits, but after a
metro.config.jsor dependency change usepnpm start:native:clear.
You do not need to touch any component to rebrand, because every component in the kit references tokens rather than literal colours.
There is no shared theme package today. apps/native/global.css and apps/web/styles/theme.css are two hand-maintained expressions of the same design system, and they are not even written the same way:
- Web declares bare names (
--background) in:root/.darkand aliases them to--color-*inapps/web/styles/shadcn-ui.css. Native declares--color-*directly. - Web uses
oklch(). Native useshsl(). - The sets already differ: native has
--color-successand--color-destructive-foreground, which web'stheme.cssdoes not define; web has--chart-1..5and the--sidebar-*family, which native does not.--radiusis0.625remon web and0.5remon native.
A rebrand means editing both files, and drift between them is a known, real risk — the values above are close to web's but are not mechanically derived from them. If drift becomes a maintenance problem in your fork, factor the palette into a shared package and generate both files from it.
Brand surfaces that are not CSS tokens — the app icon, the Android adaptive-icon background (#ffffff in apps/native/app.json), the splash screen — are build-time native config. See docs/native/building-shipping.mdoc and docs/native/config-requirements.mdoc.
Light, dark and system
The user-facing preference is tri-state (light | dark | system), and it is owned by React state, not by Uniwind. apps/native/lib/theme/theme-preference-context.tsx explains why: Uniwind stores only the resolved binary theme, so the 'system' intent is lost the moment the OS scheme flips.
| Piece | File | Responsibility |
|---|---|---|
ThemePreferenceProvider / useThemePreference | apps/native/lib/theme/theme-preference-context.tsx | Holds preference, setPreference, isReady. Calls Uniwind.setTheme() on every change. |
loadThemePreference / saveThemePreference | apps/native/lib/theme/theme-storage.ts | Persistence in expo-secure-store under the key app.themePreference. Falls back to 'system' on a missing, unrecognised or failed read. |
useNavigationTheme | apps/native/lib/theme/use-navigation-theme.ts | Builds a React Navigation Theme from the tokens. |
useThemeColors | apps/native/lib/theme/use-theme-colors.ts | Resolves tokens to JS colour strings for native APIs that take colour props. |
| Switcher UI | apps/native/app/(home)/theme.tsx | The three-row form-sheet the user actually taps. |
Uniwind.setTheme('light' | 'dark') also calls RN's Appearance.setColorScheme(), so useColorScheme() agrees with the chosen theme app-wide; setTheme('system') resets Appearance to unspecified and hands control back to the OS.
Persistence uses SecureStore rather than a plain key-value store because the app already depends on it for the session (see docs/native/authentication.mdoc), and SecureStore items are app-sandboxed on both the iOS Keychain and the Android Keystore — which is also why a flat, non-namespaced key is safe here.
The isReady gate
preference is null until the SecureStore read resolves. During that window Uniwind has not been told anything, so it renders with the OS scheme — and a user who chose dark on a light-mode device would see a white flash on every cold start. apps/native/app/_layout.tsx blocks the first frame instead:
const { preference, isReady } = useThemePreference();if (!isReady) { return ( <> <StatusBar style="light" /> <View style={{ flex: 1, backgroundColor: '#000' }} /> </> );}The black frame is deliberate: it is unbranded, so it reads as part of the launch sequence in either theme, and it is a plain style (not a className) precisely because the themed token is what is not yet trustworthy. If you add work that must happen before the first painted screen, gate it in the same place rather than adding a second splash.
React Navigation chrome
Headers, card backgrounds and the screen background underneath your content are painted by React Navigation, which reads a Theme object rather than class names. useNavigationTheme bridges the two: it resolves the tokens through useThemeColors, starts from DefaultTheme/DarkTheme depending on the colour scheme, and overrides background, card, text, border, primary and notification (mapped from --color-destructive). It is applied once, in app/_layout.tsx:
<ExpoRouterThemeProvider value={navigationTheme}> <Stack screenOptions={{ headerShown: false }} /></ExpoRouterThemeProvider>Each override falls back to the base navigation theme's own value if a token fails to resolve, so a typo in a token name degrades to the stock colour instead of rendering undefined.
When you need useThemeColors
Reach for it only when an API wants a real colour value as a prop and cannot be given a class. The native tab bar is the live example — it builds its appearance from explicit props and ignores the navigation theme entirely:
const { muted, mutedForeground, foreground } = useThemeColors();<NativeTabs backgroundColor={muted} iconColor={{ default: mutedForeground, selected: foreground }} labelStyle={{ default: { color: mutedForeground }, selected: { color: foreground }, }} disableIndicator/>The hook is reactive (it wraps Uniwind's useCSSVariable), so colours follow a theme switch without a remount. It exposes eight tokens: background, card, foreground, muted, mutedForeground, border, primary, destructive, each typed string | undefined. If you need a ninth, add it to the TOKENS array and the destructure in the same order — the file notes that the order is index-mapped.
For everything else, use a className. Anything you can express as a utility should be one.
@kit/mobile-ui
packages/mobile-ui is the native mirror of @kit/ui. Components are React Native Reusables (RNR) — shadcn-style primitives copied into src/components/ui/ by the RNR CLI, built on @rn-primitives/* — so the API is close enough to @kit/ui that porting a web feature is mostly mechanical.
The authoritative component list is the exports map in packages/mobile-ui/package.json:
| Import | Source |
|---|---|
@kit/mobile-ui/utils | src/lib/utils.ts |
@kit/mobile-ui/form | src/makerkit/form.tsx |
@kit/mobile-ui/sonner | src/makerkit/sonner.tsx |
@kit/mobile-ui/trans | src/makerkit/trans.tsx |
@kit/mobile-ui/accordion | src/components/ui/accordion.tsx |
@kit/mobile-ui/alert | src/components/ui/alert.tsx |
@kit/mobile-ui/alert-dialog | src/components/ui/alert-dialog.tsx |
@kit/mobile-ui/aspect-ratio | src/components/ui/aspect-ratio.tsx |
@kit/mobile-ui/avatar | src/components/ui/avatar.tsx |
@kit/mobile-ui/badge | src/components/ui/badge.tsx |
@kit/mobile-ui/button | src/components/ui/button.tsx |
@kit/mobile-ui/card | src/components/ui/card.tsx |
@kit/mobile-ui/checkbox | src/components/ui/checkbox.tsx |
@kit/mobile-ui/collapsible | src/components/ui/collapsible.tsx |
@kit/mobile-ui/context-menu | src/components/ui/context-menu.tsx |
@kit/mobile-ui/dialog | src/components/ui/dialog.tsx |
@kit/mobile-ui/dropdown-menu | src/components/ui/dropdown-menu.tsx |
@kit/mobile-ui/hover-card | src/components/ui/hover-card.tsx |
@kit/mobile-ui/icon | src/components/ui/icon.tsx |
@kit/mobile-ui/input | src/components/ui/input.tsx |
@kit/mobile-ui/label | src/components/ui/label.tsx |
@kit/mobile-ui/menubar | src/components/ui/menubar.tsx |
@kit/mobile-ui/native-only-animated-view | src/components/ui/native-only-animated-view.tsx |
@kit/mobile-ui/popover | src/components/ui/popover.tsx |
@kit/mobile-ui/portal | src/components/ui/portal.tsx |
@kit/mobile-ui/progress | src/components/ui/progress.tsx |
@kit/mobile-ui/radio-group | src/components/ui/radio-group.tsx |
@kit/mobile-ui/select | src/components/ui/select.tsx |
@kit/mobile-ui/separator | src/components/ui/separator.tsx |
@kit/mobile-ui/skeleton | src/components/ui/skeleton.tsx |
@kit/mobile-ui/switch | src/components/ui/switch.tsx |
@kit/mobile-ui/tabs | src/components/ui/tabs.tsx |
@kit/mobile-ui/text | src/components/ui/text.tsx |
@kit/mobile-ui/textarea | src/components/ui/textarea.tsx |
@kit/mobile-ui/toggle | src/components/ui/toggle.tsx |
@kit/mobile-ui/toggle-group | src/components/ui/toggle-group.tsx |
@kit/mobile-ui/tooltip | src/components/ui/tooltip.tsx |
There is no barrel file and no . export. Import flat, one component per subpath:
import { Icon } from '@kit/mobile-ui/icon';import { Separator } from '@kit/mobile-ui/separator';import { Text } from '@kit/mobile-ui/text';import { cn } from '@kit/mobile-ui/utils';Never write a deep path into src/components/ui/ from outside the package — a path that is not in exports is not part of the contract and will break on the next resync.
Three standing rules when you write native UI:
- Merge classes with
cn()from@kit/mobile-ui/utils(twMerge(clsx(...))), so a caller'sclassNamecan override a component's defaults. - Use semantic classes (
bg-background,text-muted-foreground,border-border) and never a hardcoded RN colour. A literal#111827is invisible to the theme switch and will look wrong in one of the two modes. - Wrap text in
Textfrom@kit/mobile-ui/textrather than RN'sText, so text-colour class context propagates to nestedIcons.
@kit/mobile-ui/form is covered in docs/native/forms.mdoc; @kit/mobile-ui/trans in docs/native/i18n.mdoc.
The rule that matters: never edit src/components/ui/
src/components/ui/ is generated. The resync command runs add --overwrite, which replaces those files in place with no diff and no prompt. Any project-specific behaviour you add there is lost the next time anyone syncs the component library.
Project-specific work goes in packages/mobile-ui/src/makerkit/, a sibling directory the CLI never touches. It currently holds:
| File | Export | What it is |
|---|---|---|
form.tsx | Form, FormField, FormItem, FormLabel, FormControl, FormDescription, FormMessage, useFormField | react-hook-form bindings, mirroring @kit/ui/form |
sonner.tsx | Toaster, toast | House toast surface over sonner-native, defaulted to position="top-center" |
trans.tsx | Trans | RN-flavoured mirror of @kit/ui's Trans, same public API |
This is the same split packages/ui uses on web: packages/ui/src/shadcn/ is generated and disposable, packages/ui/src/makerkit/ is yours. Learning one convention gets you both.
If a generated component genuinely needs different behaviour, prefer wrapping it in src/makerkit/ over editing it. If you must edit the original, treat that edit as a patch you will have to re-apply, and record it — a resync will silently drop it.
Adding an RNR component
RNR ships more components than the kit vendors. To pull one in:
cd packages/mobile-uipnpm exec cli add <component> --styling-library uniwindThe CLI is configured by packages/mobile-ui/components.json (styling library uniwind, icon library lucide, and — importantly — tailwind.css pointing at ../../apps/native/global.css, so it reads your tokens rather than writing its own). To resync everything:
pnpx @react-native-reusables/cli@latest add --all --yes --overwrite --styling-library uniwindpackages/mobile-ui/package.json defines "rnr:add": "rnr add", but @react-native-reusables/cli@0.7.1 declares "bin": "bin.cjs" without a name, so pnpm links the executable as cli, not rnr. There is no rnr on the path and the script fails with a command-not-found. Use pnpm exec cli add … (or the pnpx form above) until the script is corrected.
Every add writes files that import each other through @/ aliases. packages/mobile-ui/tsconfig.json declares no paths for @/, so those imports do not resolve — rewrite them to relative paths before doing anything else:
cd packages/mobile-ui/src/components/uifor f in *.tsx; do sed -i.bak \ -e "s|from '@/components/ui/\([^']*\)'|from './\1'|g" \ -e "s|from '@/lib/utils'|from '../../lib/utils'|g" \ -e "s|from '@/lib/\([^']*\)'|from '../../lib/\1'|g" \ -e "s|from '@/hooks/\([^']*\)'|from '../../hooks/\1'|g" \ "$f" rm "$f.bak"doneThen add the new component to the exports field of packages/mobile-ui/package.json — until you do, @kit/mobile-ui/<name> is not importable — and run pnpm typecheck.
One packaging note: RNR installs its @rn-primitives/* dependencies with pnpm add under the hood, and this workspace's catalogMode: prefer rewrites those into catalog: entries in pnpm-workspace.yaml automatically. Check that diff rather than reverting it.
Adding your own component
Two homes, one question:
| Home | Use when | Import as |
|---|---|---|
packages/mobile-ui/src/makerkit/ | The component is a pure, cross-platform UI primitive with no knowledge of your domain, your API or your app's contexts | @kit/mobile-ui/<name> (add it to exports) |
apps/native/features/makerkit/components/ | The component knows something about this app — it reads translations, calls ~/features/core, or composes domain state | ~/features/makerkit/components/<name> |
The dividing line is dependencies, not reusability. Anything that imports ~/features/*, ~/lib/* or the app's i18n cannot live in the package. Layout glue that composes domain features (the home header, the tab stack) goes higher still, in apps/native/components/. The full layer model and the one-way import rule are in docs/native/project-structure.mdoc.
Before you build a shared widget, check what already exists in apps/native/features/makerkit/components/:
| Component | What it does |
|---|---|
AppLogo | Brand mark for the home header. Bundles the web favicon as a raster PNG via a relative require; className overrides the default size-6. |
ErrorState / InlineErrorState | Full-screen and inline error surfaces: generic user-facing message, a __DEV__-only diagnostic line, a retry action, optional sign-out escape. Never leaks error detail in production. |
KeyboardAvoidingScreen | Full-screen container for headerless form screens. Animated paddingBottom on the UI thread plus a ScrollView overflow escape. |
LoadingState | Centred ActivityIndicator on a bg-background full-screen View. |
OtpInput | Six-cell code entry: one hidden TextInput overlaid on the visual cells, so paste, autofill, backspace and screen-reader focus all work. length defaults to 6. |
PictureControls | Shared change/remove picture controls for the personal avatar and the team logo. Uploads to the account_image bucket and persists via a caller-supplied persist mutation. |
SearchInput | Controlled search field with a leading search icon and a clear button that appears once there is a value. |
SettingsLinkRow | Tappable settings row: leading Lucide icon, title, optional description, trailing chevron. |
SettingsSection | Titled section wrapper with an optional description, for grouping settings rows. |
WebOnlyNotice | Standard card telling the user a feature lives on the web app. Takes an i18n featureKey. See docs/native/covered-features.mdoc. |
WorkspaceAvatar | Personal/team avatar with web-parity fallbacks (generic user icon or the label's initial). Note: RN's <Image> cannot decode SVG, so an SVG source falls through to the fallback. |
Icons
Icons come from lucide-react-native, rendered through the Icon wrapper from @kit/mobile-ui/icon. You pass the Lucide component as as and style it with classes — this is how app/(home)/theme.tsx does it:
import { Check, Sun, type LucideIcon } from 'lucide-react-native';import { Icon } from '@kit/mobile-ui/icon';<Icon as={Sun} className="text-foreground size-5" /><Icon as={Check} className="text-primary size-5" />Icon defaults to text-foreground size-5 and inherits the surrounding text colour context, so an icon inside a muted row picks up the muted colour without being told.
Icon is built with withUniwind forwarding exactly two style properties — width and color. Every other class you put on it (mt-2, ml-1, shrink-0, transforms) is resolved and then dropped, with no warning. Put layout classes on a wrapping View, or pass style directly. The same caveat applies to every iconClassName prop in @kit/mobile-ui, since they all funnel into this className.
For icons that are decoration rather than information, set accessibilityElementsHidden / importantForAccessibility="no-hide-descendants" on the wrapper, as AppLogo does.
Two type-level gotchas
placeholderClassName. Both apps/native/uniwind-env.d.ts and packages/mobile-ui/src/uniwind-env.d.ts augment RN's TextInputProps with an optional placeholderClassName, a leftover from an RNR-vs-Uniwind prop mismatch. Nothing in the repo passes it today — the vendored input.tsx and textarea.tsx colour their placeholders with Uniwind's own placeholderTextColorClassName, which resolves through accentColor and therefore needs an accent-* utility (accent-muted-foreground), not a text-* one. A text-* class there yields undefined.
export {} is load-bearing. Both uniwind-env.d.ts files declare export {}. Without it the file is a global script rather than a module, and the declare module 'react-native' block inside would replace the react-native module declaration instead of augmenting it — breaking every import { Platform } from 'react-native' in the app. If you touch those files, keep the export {}.
Neither file needs manual maintenance otherwise. uniwind-types.d.ts next to them is generated by Metro on first run, is gitignored, and is referenced from apps/native/tsconfig.json.