alouette
alouette-setup
Wire alouette into an Expo / React Native app: the metro plugin that compiles its styles, the CSS entry point and the source globs that decide which classes survive, AlouetteProvider and the theme variables it needs to render colors on native, the safe-area provider, and loading the Sora and Chivo Mono weights so bold text is the right typeface. Load when bootstrapping a project, when alouette classes render unstyled, or when fonts/bold weights look wrong. Covers ios, android and web.
alouette — Setup
alouette is styled with NativeWind v5 / Tailwind CSS v4. An app needs five things wired before any component renders correctly: the metro plugin, the PostCSS config, the CSS entry with source globs, the provider, and the fonts. Everything targets ios/android/web from the same code.
Setup
metro.config.cjs:
const { withAlouetteConfig } = require("alouette/metro.cjs");
const { getDefaultConfig } = require("expo/metro-config.js");
const config = getDefaultConfig(__dirname);
module.exports = withAlouetteConfig(config);postcss.config.mjs at the app package root — this is what actually runs Tailwind. @tailwindcss/postcss ships as a dependency of alouette, so apps do not install it; they only add this config file:
export default { plugins: { "@tailwindcss/postcss": {} } };Use the .mjs extension so it loads as ESM regardless of the package's "type" field.
src/global.css (imported once, at the app entry). Add an @source for alouette's source and one for your own app source — both are scanned independently of the JS bundle, and anything not covered is purged:
@import "alouette/global.css";
@source "./**/*.{ts,tsx}"; /* the app's own className / tv() literals */
@source "../node_modules/alouette/src/**/*.{ts,tsx,js}"; /* alouette source */alouette/global.css is an aggregator of alouette/core.css (structural; its shadows read the palette's shadow color tokens) + alouette/default-palette.css (the default palette, sRGB hex). Wide-gamut color is opt-in — add @import "alouette/default-palette-oklch.css"; after it to give web the display-p3 ramp. To ship a custom palette instead of the default, import core.css + your own generated palette CSS and pass the generated themeVariables to AlouetteProvider — see alouette-theming/SKILL.md. The @source lines are unchanged.
In a monorepo where alouette is hoisted to the repo root node_modules (Yarn node-modules linker, pnpm hoisted, etc.), the path resolves from the repo root, not the app — adjust the depth accordingly:
@source "../../../node_modules/alouette/src/**/*.{ts,tsx,js}";A wrong glob matches zero files and fails silently (no error) — utilities are simply purged and components render unstyled.
App entry — load fonts (native), then wrap the tree in AlouetteProvider:
import "./global.css";
import {
Sora_400Regular as SoraRegular,
Sora_700Bold as SoraBold,
Sora_800ExtraBold as SoraExtraBold,
useFonts,
} from "@expo-google-fonts/sora";
import { AlouetteProvider } from "alouette";
import { themeVariables } from "alouette/defaultThemeVariables";
export function App() {
// Native font loading. On web, load the same fonts via a Google Fonts
// <link> instead (see "Web: load fonts from Google Fonts" below).
const [fontsLoaded] = useFonts({ SoraRegular, SoraBold, SoraExtraBold });
if (!fontsLoaded) return null;
return (
<AlouetteProvider themeVariables={themeVariables}>
<Screen />
</AlouetteProvider>
);
}AlouetteProvider reads the OS color scheme (useColorScheme) and applies light or dark as the root theme, so base tokens resolve app-wide.
themeVariables is required by the prop type on every platform, but it is consumed only on native, where it is the mirror of the palette CSS for the props that can't take a className (placeholderTextColor, native Switch colors, gradient stops, SVG tint). The web build of AlouetteProvider ignores it: web theming is a className the palette CSS resolves. Pass the map that matches the palette CSS the app imports:
| Palette | CSS | themeVariables |
|---|---|---|
| default | alouette/global.css | themeVariables from alouette/defaultThemeVariables |
| default + wide gamut | + alouette/default-palette-oklch.css | unchanged — the overlay is CSS-only |
| the app's own | alouette/core.css + its generated palette CSS | its generated themeVariables module |
The default map lives at the alouette/defaultThemeVariables subpath — it is not re-exported from the alouette root entry. It is sRGB hex on every platform, which is the only format React Native can parse; the wide-gamut ramp is purely a web stylesheet and needs no matching map.
An app that ships its own palette does not generate it at runtime: a build script calls writeTheme from alouette/theme-generator, which writes both halves to disk — the palette CSS (plus an optional OKLCH overlay, palette-oklch.css) and a themeVariables module in hex — the way alouette's own scripts/build-css.ts writes the default palette. The app imports the generated files:
// scripts/build-theme.ts
import { writeTheme } from "alouette/theme-generator";
writeTheme({
outDir: "src",
overrides: { brand: { type: "accent", hue: 300 } },
});/* src/global.css — core.css + the generated palette, not alouette/global.css */
@import "alouette/core.css";
@import "./palette.css"; /* writeTheme output */
@import "./palette-oklch.css"; /* optional wide-gamut half */import { AlouetteProvider } from "alouette";
import { themeVariables } from "./themeVariables"; // writeTheme output
<AlouetteProvider themeVariables={themeVariables}>
<Screen />
</AlouetteProvider>;The generated themeVariables module is hex on every platform and has no OKLCH counterpart — the web provider ignores themeVariables entirely, so only the hex map matters. Pass srgbOnly: true to skip the OKLCH CSS.
Those two imports are the only wiring difference — everything else on this page (metro, postcss, @source globs, fonts) is identical. See alouette-theming/SKILL.md for the palette params writeTheme takes.
Sora (body + heading) is the only required font. Add Chivo Mono only if the app uses font-mono utilities:
import {
ChivoMono_400Regular as ChivoMonoRegular,
ChivoMono_700Bold as ChivoMonoBold,
ChivoMono_800ExtraBold as ChivoMonoExtraBold,
} from "@expo-google-fonts/chivo-mono";
useFonts({
SoraRegular,
SoraBold,
SoraExtraBold,
ChivoMonoRegular,
ChivoMonoBold,
ChivoMonoExtraBold,
});SafeAreaProvider (only if needed)
Don't add SafeAreaProvider preemptively — many setups (e.g. expo-router) already provide one. Add it only if a component throws a safe-area context error:
import { SafeAreaProvider } from "alouette";
<SafeAreaProvider>
<AlouetteProvider themeVariables={themeVariables}>
<Screen />
</AlouetteProvider>
</SafeAreaProvider>;DensityScope: compact controls for a desktop-only app
Every control is 44px tall by default, the touch target. An interface driven by a mouse only (a desktop web app, an admin) wraps its tree in a compact DensityScope: fields, buttons, menus, segmented bars and nav items drop to 36px (Button sizes 32 / 36 / 44px). Type sizes do not change.
import { DensityScope } from "alouette";
<AlouetteProvider themeVariables={themeVariables}>
<DensityScope density="compact">
<App />
</DensityScope>
</AlouetteProvider>;Scopes nest (density="comfortable" restores the touch size for a subtree), and a Popover, Menu or Modal keeps the density of what opened it. A custom control follows it by taking its height from the control-* tokens (min-h-control-md), never min-h-[44px]; useDensity() and controlHeightsPx cover a size set from JS.
Web: load fonts from Google Fonts
On web, prefer a Google Fonts stylesheet over useFonts. With Expo Router, add app/+html.tsx:
import type { PropsWithChildren } from "react";
export default function Root({ children }: PropsWithChildren) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Sora:wght@400;700;800&display=swap"
/>
</head>
<body>{children}</body>
</html>
);
}Append &family=Chivo+Mono:wght@400;700;800 to the URL if you use font-mono.
Common Mistakes
CRITICAL Missing postcss.config — Tailwind never runs
Symptom: components render unstyled (only alouette's hotpink body fallback shows), and the build logs spam Warning: Unknown at rule: @utility / @source / @theme. Those warnings are the diagnostic signature — Tailwind directives are reaching lightningcss un-expanded because Tailwind never ran.
Mechanism: withAlouetteConfig → withNativewind delegates CSS compilation to Expo's Metro transform worker, which runs Tailwind only if it finds a postcss.config file at the project root. Absent → CSS passes straight to lightningcss verbatim and zero utilities are emitted.
Fix — add postcss.config.mjs at the app package root:
export default { plugins: { "@tailwindcss/postcss": {} } };@tailwindcss/postcss is a dependency of alouette, so no install is needed.
Source: packages/storybook-native-app/postcss.config.mjs
CRITICAL global.css missing @source, or wrong path in a monorepo
Wrong (no @source, or a path that resolves to nothing):
@import "alouette/global.css";Correct:
@import "alouette/global.css";
@source "./**/*.{ts,tsx}"; /* the app's own classes */
@source "../node_modules/alouette/src/**/*.{ts,tsx,js}"; /* alouette source */Tailwind v4 only emits classes it finds in scanned files. Two failure modes, both producing the same silent unstyled result with no error:
- No @source for alouette's source → every alouette utility is purged.
- No @source for the app's own source → the app's own classes (e.g. arbitrary values like from-[#f39c12], bg-linear-to-t) are purged while alouette's still work — easy to misdiagnose.
- In a monorepo where alouette is hoisted to the repo root node_modules, ../node_modules/alouette/src resolves to nothing. Use the correct depth, e.g. @source "../../../node_modules/alouette/src/**/*.{ts,tsx,js}".
Note: @source is a text-scan of alouette's shipped src/*.tsx (the verbatim className / tv() string literals) — independent of the JS bundle, which Metro resolves to the compiled dist. The two pipelines are decoupled, which is why the scan targets src and not the build output.
Source: packages/storybook-native-app/src/global.css
CRITICAL Metro config omits withAlouetteConfig
Wrong:
const { getDefaultConfig } = require("expo/metro-config.js");
module.exports = getDefaultConfig(__dirname);Correct:
const { withAlouetteConfig } = require("alouette/metro.cjs");
const { getDefaultConfig } = require("expo/metro-config.js");
module.exports = withAlouetteConfig(getDefaultConfig(__dirname));withAlouetteConfig enables the NativeWind / react-native-css transform; without it, className styles never compile on native.
Source: packages/storybook-native-app/metro.config.cjs, packages/alouette/metro.cjs
CRITICAL App tree not wrapped in AlouetteProvider
Wrong:
export function App() {
return <Screen />;
}Correct:
import { AlouetteProvider } from "alouette";
import { themeVariables } from "alouette/defaultThemeVariables";
export function App() {
return (
<AlouetteProvider themeVariables={themeVariables}>
<Screen />
</AlouetteProvider>
);
}AlouetteProvider applies the OS light/dark scheme as the root ScopedTheme. Without it, base tokens (bg-surface, text-sharp, text-accent) have no resolved values and components render with missing colors.
Source: packages/alouette/src/core/AlouetteProvider.tsx
CRITICAL AlouetteProvider without themeVariables
Wrong:
<AlouetteProvider>
<Screen />
</AlouetteProvider>Correct:
import { themeVariables } from "alouette/defaultThemeVariables";
<AlouetteProvider themeVariables={themeVariables}>
<Screen />
</AlouetteProvider>;themeVariables has no default: the provider feeds it straight into NativeThemeVariablesContext, whose context default is unset. Omitting it is a type error, and on native ScopedTheme then indexes into an undefined map and throws (web survives it — the web provider never reads the prop, which is exactly how this ships broken to device after testing fine in a browser). Pass the map matching the palette CSS the app imports — the default palette's map from alouette/defaultThemeVariables (not exported from the alouette root entry), or, for a custom palette, the themeVariables module writeTheme generated alongside the palette CSS. Don't call into alouette/theme-generator here: it is a node-only build-time API, not a runtime call.
Source: packages/alouette/src/core/AlouetteProvider.tsx, src/core/NativeThemeVariablesContext.ts
HIGH Bold / extrabold fonts not loaded
Wrong:
useFonts({ SoraRegular: Sora_400Regular });Correct:
useFonts({
SoraRegular: Sora_400Regular,
SoraBold: Sora_700Bold,
SoraExtraBold: Sora_800ExtraBold,
});On native, bold and extrabold are distinct font files. If only the regular weight is loaded, font-body-bold / font-heading-extrabold silently fall back to regular. (Load the matching Chivo Mono weights too, but only if the app uses font-mono.)
Source: packages/storybook-native-app/src/App.tsx
See also: alouette-theming/SKILL.md — once setup is done, the token/accent model is what you style with.