An Expo application
The native side of a shared kit: Expo, NativeWind, the provider, safe areas, the bottom bar and the export that proves it bundles.
This is the native half of the monorepo blueprint. It assumes an Expo application
created with create-expo-app and a shared kit that imports asheeui.
The renderer supplies components, not a platform. Expo still owns the application, the router, the status bar and the build; AsheeUI supplies the design language and the components inside them.
Versions this guide is written for
| Package | Version |
|---|---|
| Expo SDK | 58 |
| React Native | 0.88 |
| React | 19 |
| NativeWind | 4.2.7 (Tailwind 3.4) |
@asheeui/native |
2.8.0 |
1. Install
npx expo install nativewind react-native-reanimated react-native-safe-area-context
npm install asheeui @asheeui/native
react-native-reanimated is what NativeWind's press and layout transitions ask for, and
react-native-safe-area-context is what the page shell measures its safe areas with. Install the React
Native copy the application already has rather than a second one.
Do not install react-native-css-interop yourself. NativeWind 4.2.7 brings its own copy, and that
copy is the half that turns a compiled class into a style. A pin of your own resolves instead of the
copy NativeWind brings: the Tailwind compile succeeds, the bundle contains every class name, and every
component renders unstyled with no warning anywhere.
The shared kit also needs the native TypeScript condition:
// tsconfig.json
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"customConditions": ["react-native"],
"types": ["node"]
}
}
customConditions is what makes asheeui resolve to @asheeui/native's types in the kit. types: ["node"] is only needed when the shared sources mention a Node global (they should not; it is cheaper
than a confusing error).
2. Babel
// babel.config.js
module.exports = function (api) {
api.cache(true);
return {
presets: [
["babel-preset-expo", { jsxImportSource: "nativewind" }],
"nativewind/babel",
],
};
};
Two presets, and the order matters. babel-preset-expo is the platform's own JSX transform, and
NativeWind's preset runs after it because the CSS transform works on the JSX output. NativeWind's
preset adds the Reanimated plugin, so the application does not list it again.
3. Metro
// metro.config.js
const { getDefaultConfig } = require("expo/metro-config");
const { withNativeWind } = require("nativewind/metro");
const path = require("node:path");
const projectRoot = __dirname;
const workspaceRoot = path.resolve(projectRoot, "../..");
const config = getDefaultConfig(projectRoot);
config.watchFolders = [workspaceRoot];
config.resolver.nodeModulesPaths = [
path.resolve(projectRoot, "node_modules"),
path.resolve(workspaceRoot, "node_modules"),
];
config.resolver.unstable_enablePackageExports = true;
module.exports = withNativeWind(config, { input: "./global.css" });
The workspace settings are what let Metro see the shared kit and read its exports map, which is how the
react-native condition applies. Do not set disableHierarchicalLookup = true: pnpm places a
package's own dependencies beside its real path in the virtual store, and hierarchical lookup is what
finds them.
4. The stylesheet
/* global.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
// App.tsx
import "./global.css";
The file has to be both named in metro.config.js and imported. One that is named and never imported is
a bundle with no styles in it, which is the first thing to check when nothing is styled.
5. Tailwind: the umbrella's preset
// tailwind.config.js
const asheeui = require("asheeui/tailwind-preset");
module.exports = {
presets: [asheeui],
content: [
"./App.tsx",
"./src/**/*.{ts,tsx}",
"../../packages/kit/src/**/*.{ts,tsx}",
...asheeui.content,
],
};
The preset is three things: NativeWind's preset, the eight colour roles the components reference by
name, and the framework's own compiled modules in content. The spread is not decoration — Tailwind 3
replaces a preset's content with the application's own array, so the framework's glob has to reach the
application's list.
Do not state the roles as colours in theme.extend.colors. The preset states each role as the custom
property the provider paints; a hex there fixes the role at build time and stops it following
defaultTheme and color.
6. The root
// App.tsx
import "./global.css";
import { StatusBar } from "expo-status-bar";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { AsheeUIProvider, ToastProvider } from "asheeui";
import { config } from "@acme/kit";
import { Shell } from "./src/shell";
export default function App() {
return (
<SafeAreaProvider>
<AsheeUIProvider config={config}>
<ToastProvider>
<Shell />
<StatusBar style="auto" />
</ToastProvider>
</AsheeUIProvider>
</SafeAreaProvider>
);
}
Order matters: the safe-area provider is outside because the page shell measures its insets; the configuration provider is outside every component; the toast provider is above the screens that raise toasts.
7. The shell: header and bottom bar
A phone's navigation is a header plus the destinations at the edge a thumb reaches. The shared screens do not know about either.
// src/shell.tsx
import { useState } from "react";
import { Pressable } from "react-native";
import { Navbar, Page, PageContent, TabBar } from "asheeui";
import {
DashboardScreen,
NutritionScreen,
ProgressScreen,
SettingsScreen,
WorkoutDetailScreen,
WorkoutsScreen,
} from "@acme/kit";
const links = [
{ label: "Today", href: "today" },
{ label: "Workouts", href: "workouts" },
{ label: "Nutrition", href: "nutrition" },
{ label: "Progress", href: "progress" },
];
/** The native replacement for an anchor: the application's own navigator. */
function TabLink({
href,
children,
onNavigate,
}: {
href: string;
children?: React.ReactNode;
onNavigate: (href: string) => void;
}) {
return <Pressable onPress={() => onNavigate(String(href))}>{children}</Pressable>;
}
export function Shell() {
const [route, setRoute] = useState("today");
const [workoutId, setWorkoutId] = useState<string | null>(null);
const navigate = (href: string) => {
setWorkoutId(null);
setRoute(href);
};
const link = { component: TabLink, props: { onNavigate: navigate } };
return (
<Page>
<Navbar
brand="Pulse"
links={links}
link={link}
actions={<SettingsAction onPress={() => navigate("settings")} />}
/>
<PageContent>
{workoutId ? (
<WorkoutDetailScreen id={workoutId} onBack={() => setWorkoutId(null)} />
) : route === "workouts" ? (
<WorkoutsScreen onOpenWorkout={setWorkoutId} onNavigate={navigate} />
) : route === "nutrition" ? (
<NutritionScreen onNavigate={navigate} />
) : route === "progress" ? (
<ProgressScreen onNavigate={navigate} />
) : route === "settings" ? (
<SettingsScreen onNavigate={navigate} />
) : (
<DashboardScreen
onNavigate={navigate}
onOpenWorkout={() => setWorkoutId("lower-body")}
/>
)}
</PageContent>
<TabBar items={links} link={link} />
</Page>
);
}
Every screen in the example is imported from the shared kit. The only native file is the shell, and the
only difference from the web shell is the TabBar and the state-based router.
8. Check the bundle
npx expo export --platform ios --output-dir dist
The export is the fastest proof that the application bundles: it resolves every import, runs Babel and
NativeWind, and writes a Hermes bundle. A missing peer, a wrong Metro setting or a class compiled outside
the content list fails here rather than on a device.
npx expo start # Metro, for a simulator or Expo Go
npx expo run:ios # a development build
When nothing is styled
The failure has one shape: the right words, the right structure, no design at all. Check these in order.
- The stylesheet is never imported.
global.cssis named inmetro.config.jsand not imported byApp.tsx. - The framework is not in
content....asheeui.contentis missing, so the framework's classes compile to nothing while the application's own components are styled. react-native-css-interopis pinned away from NativeWind's copy. The compile succeeds and nothing is applied.- A role is stated in
theme.extend.colors. The runtime palette stops following the configuration. - The published artifact emits through React's JSX runtime. This was a defect before 2.5.0 and is
fixed; upgrade
@asheeui/native.
The provider also checks the pipeline and warns once with the cause it found. The same diagnosis is available as data:
import { verifyNativeWind } from "@asheeui/native";
const report = verifyNativeWind();
if (!report.ok) {
for (const check of report.checks.filter((check) => !check.ok)) {
console.log(check.summary, check.remedy);
}
}
What is different from the web
The full ledger is on React Native. The practical list for an application:
| Concern | Web | Expo |
|---|---|---|
| Navigation | Navbar at the top |
Navbar + TabBar |
| Press | onClick or onPress |
onPress |
| Link | href renders an anchor |
href opens through Linking |
| Responsive | media queries | useBreakpoint and Grid columns* |
| Overlays | portal and z-index |
platform modal and sheet |
| Keyboard | the browser's | the system keyboard |
| Testing | Vitest + Testing Library on the DOM | Jest + React Native Testing Library |