Skip to contentAsheeUIAsheeUI

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.

  1. The stylesheet is never imported. global.css is named in metro.config.js and not imported by App.tsx.
  2. The framework is not in content. ...asheeui.content is missing, so the framework's classes compile to nothing while the application's own components are styled.
  3. react-native-css-interop is pinned away from NativeWind's copy. The compile succeeds and nothing is applied.
  4. A role is stated in theme.extend.colors. The runtime palette stops following the configuration.
  5. 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