Skip to content

alouette

A modern, customizable design system built on top of NativeWind v5 with configurable defaults

alouette-theming

470 linesSource

Re-theme a subtree with accents (brand, danger, info, success, warning) and light/dark modes. Use the accent prop, AccentScope, or ScopedTheme; children always consume base tokens (bg-surface, text-accent, text-sharp, text-muted, border-muted). Tokens are className-only: alouette exports no JS token-read hook. Read the current mode with useCurrentMode. For an accent that toggles at runtime without remounting, use StableAccentScope; inside a portal (a modal or any other overlay), where the scope escapes the themed subtree, use PortalAccentScope. All three, and the pressables, take accent="neutral": the plain mode theme, which drops an inherited accent; with variant="soft" it makes a Button the secondary beside an accented one. A stored light/dark choice is a ColorModePreference ("light" | "dark" | "system") the app passes to ScopedTheme as is: "system" is resolved in CSS on web, so a prerendered page never flashes the wrong mode. Load when applying colors, accents or dark mode, or when shipping a custom palette.

alouette — Theming with modes and accents

alouette colors come from theme tokens, not the raw Tailwind palette. A theme is a light/dark mode optionally combined with an accent. Tokens cascade down the tree: components use base tokens (bg-surface, text-accent, text-sharp, text-muted, border-muted) and inherit the resolved value from the nearest scope. Setting an accent re-themes a whole subtree.

Accent = "brand" | "danger" | "info" | "success" | "warning", and AccentOrNeutral adds "neutral" — both exported, and AccentOrNeutral is what every public accent prop takes.

Setup

Most alouette components — Text, Box, Button, Message, … — take an accent prop that re-themes their subtree. Prefer the prop:

tsx
import { Box, Text } from "alouette";

<Box accent="danger" className="surface">
  <Text className="text-accent">Something went wrong</Text>
</Box>

<Text accent="brand" className="text-accent">Brand-accented text</Text>;

Use AccentScope only to re-theme a group of children at once, or children that don't accept an accent prop:

tsx
import { AccentScope } from "alouette";

<AccentScope accent="brand">
  <Header />
  <Body />
</AccentScope>;

Core Patterns

Tokens are className-only

alouette exports no hook to read a token value in JS. Apply every color through a className (bg-surface, text-accent, border-muted) — that is the only path that works on both platforms, because the two resolve a theme differently:

  • web — ScopedTheme renders a display: contents element carrying the theme name as a className; the palette CSS resolves the variables. The themeVariables map is not consulted at all.
  • native — ScopedTheme pushes the theme's variables through NativeWind's VariableContextProvider, from the themeVariables map given to AlouetteProvider.

The handful of props that cannot take a className (native Switch colors, placeholderTextColor, SVG tint, the Expo browser chrome) are handled inside alouette itself, on native only; web styles them with CSS.

Force a mode on a subtree

tsx
import { AccentScope } from "alouette";

<AccentScope mode="dark" accent="brand">
  {children}
</AccentScope>;

Drop an inherited accent

accent takes "neutral" alongside the five accents — on AccentScope, StableAccentScope and PortalAccentScope alike. It resolves to the plain mode theme (light / dark), so the subtree falls back to the neutral tokens even under an accented ancestor. This is not the same as omitting accent: omitting it renders no scope at all and the ancestor's accent keeps cascading.

tsx
<AccentScope accent="danger">
  <Text className="text-accent">Delete</Text> {/* danger */}
  <AccentScope accent="neutral">
    <Text className="text-accent">Details</Text>{" "}
    {/* back to the mode's accent */}
  </AccentScope>
</AccentScope>

InputTextAutocomplete uses it for its menu: the field is accented, the listbox that opens next to it is not.

The pressables take it too — Button, IconButton, PressableBox, PressableListItem — where it is more than an escape hatch: accent="neutral" is the neutral button, the same material (ground, shadow, hover/focus/press) on the grayscale palette — an accent like any other: a white card with sharp ink for the default tonal variant, and for filled the sharp ink turned into a ground (black under a white label in light mode, white under a dark label in dark). Beside an accented action, the secondary one is neutral and soft — a neutral text button — never variant="outlined" (alouette-actions/SKILL.md).

tsx
<Button accent="neutral" variant="soft" text="Cancel" onPress={close} />
<Button text="Save" onPress={save} />

Read the active mode / theme

tsx
import { useCurrentMode, useCurrentTheme } from "alouette";

const mode = useCurrentMode(); // "light" | "dark"
const theme = useCurrentTheme(); // e.g. "dark_brand"

Both resolve a system scope to the OS mode. On web they are hydration-safe: they read light during the server render and hydration, then the client's mode on the next render — so a JS read (a status bar style, a computed color) never mismatches the prerendered HTML.

Apply a stored light/dark preference

A stored choice is a ColorModePreference: "light" | "dark" | "system". Applying it is the app's job — pass it as is to a ScopedTheme around the tree; ColorModePicker (alouette-inputs/SKILL.md) is the ready-made control that reports the choice. AlouetteProvider already applies system.

A system scope renders the system class (and system_<accent> under an accent), which the palette CSS resolves with prefers-color-scheme: the prerendered HTML and the hydration render are identical, and a dark-mode visitor never sees the light page first. :root follows it as long as a system scope is in the document, for html and body. An explicit light / dark keeps its own classes, which the media query never touches. Native has no prerender and resolves system with useColorScheme.

Never resolve the preference before applying it: a ScopedTheme given useResolvedColorMode(preference) renders light on the server, and the page flashes. useResolvedColorMode(preference) and useSystemColorMode() are for what reads the mode in JS, and are hydration-safe like useCurrentMode.

tsx
import {
  type ColorModePreference,
  ColorModePicker,
  ScopedTheme,
} from "alouette";

const [preference, setPreference] = useState<ColorModePreference>("system");

<ScopedTheme theme={preference}>
  <ColorModePicker value={preference} onValueChange={setPreference} />
</ScopedTheme>;

Toggle an accent without remounting the subtree

tsx
import { StableAccentScope } from "alouette";

const [pendingRemoval, setPendingRemoval] = useState(false);

<StableAccentScope accent={pendingRemoval ? "danger" : undefined}>
  <IconButton
    onHoverIn={() => setPendingRemoval(true)}
    onHoverOut={() => setPendingRemoval(false)}
    onPress={onRemove}
  />
</StableAccentScope>;

Theme a portalled subtree

A portal renders outside the themed DOM subtree, so on web the theme className of its ancestors no longer applies and the accent has to be rebuilt from the base mode. PortalAccentScope does that — it is what Modal uses internally, so Modal's own accent prop already works. Reach for it directly only when you portal something yourself:

tsx
import { PortalAccentScope } from "alouette";

<RNModal transparent visible={visible}>
  <PortalAccentScope accent="danger">{overlay}</PortalAccentScope>
</RNModal>;

Web nests two scopes — the outer one re-declares the full light/dark token set the portal escaped, the inner one layers the accent on top — and both stay mounted, so toggling accent never remounts the subtree. Native has no portal problem (ScopedTheme pushes merged variables through React context, which crosses wherever the host renders the tree), so there it is a single StableAccentScope.

Both StableAccentScope and PortalAccentScope take mode to force light or dark, as AccentScope does. Without an accent they keep the inherited accent and re-apply it in that mode: <PortalAccentScope mode="dark"> under light_danger renders dark_danger.

Ship a custom palette for the existing accents

An app re-colors the existing accents (brand, danger, info, success, warning, grayscale) from a build script: writeTheme from alouette/theme-generator (node-only, never imported by app code) writes the palette CSS, an optional OKLCH overlay and the themeVariables map. The app then imports alouette/core.css + its palette instead of alouette/global.css, and passes its own map to AlouetteProvider — the CSS and the map are two halves of one theme, and the default map under a custom palette leaves every native token read on the default colors. The accent set is fixed: this re-colors accents, it never adds one.

ts
// scripts/build-theme.ts
import { writeTheme } from "alouette/theme-generator";

writeTheme({
  outDir: "src",
  overrides: { brand: { type: "accent", hue: 300 } },
});

Output files, the CSS imports, generateTheme in memory and the PaletteSpec parameters: references/custom-palette.md.

Color format: OKLCH on web, hex on native

Every palette is computed in OKLCH and emitted twice — sRGB hex (what native renders; React Native's color parser takes hex/rgb/hsl/hwb only) and OKLCH with display-p3 chroma headroom (what web renders: same lightness and hue ramp, more vivid accents on wide-gamut screens). Hex is the baseline; OKLCH is a separate opt-in stylesheet, imported after the hex palette:

palette CSSthemeVariables map
webpalette-oklch.css / alouette/default-palette-oklch.css, if importednot read — web theming is CSS only
nativepalette.css (plain hex)themeVariables.ts (hex)

The OKLCH CSS is additive — it re-declares the same variables behind @supports (color: oklch(0 0 0)), and the native compiler drops the feature query and keeps the hex. The palette CSS uses the same trick for the .<theme> blocks (the twelve explicit ones and the six .system* ones), which are a web-only mechanism (native themes through VariableContextProvider, not classNames): they sit behind @supports (display: contents) and never reach the native bundle — which is why native needs the themeVariables map at all.

The map has no OKLCH variant: the web build of AlouetteProvider ignores themeVariables entirely, so writeTheme emits the hex map only — the one native consumes. The default is alouette/defaultThemeVariables (hex, every platform).

Common Mistakes

HIGH Hardcoding raw Tailwind colors instead of tokens

Wrong:

tsx
<View className="bg-blue-500">
  <Text className="text-gray-600">Hi</Text>
</View>

Correct:

tsx
<Box accent="brand" className="surface">
  <Text className="text-accent">Hi</Text>
</Box>

Raw palette classes (bg-blue-500, text-gray-600) ignore the alouette theme, so they do not adapt to mode or accent and break dark mode.

Source: CLAUDE.md (Theming and semantic roles); src/ui/containers/AccentScope.tsx

MEDIUM Setting color manually instead of an accent

Wrong:

tsx
<Box>
  <Text style={{ color: "#c00" }}>Error</Text>
</Box>

Correct:

tsx
<Box accent="danger">
  <Text className="text-accent">Error</Text>
</Box>

Setting accent re-themes the subtree so children resolve base tokens against the accent + current mode; a hardcoded color duplicates theme logic and skips mode adaptation.

Source: packages/alouette/src/ui/containers/Box.tsx, AccentScope.tsx

MEDIUM Wrapping an accent-capable component in AccentScope

Wrong:

tsx
<AccentScope accent="brand">
  <Text className="text-accent">Title</Text>
</AccentScope>

Correct:

tsx
<Text accent="brand" className="text-accent">
  Title
</Text>

Text, Box, Button, Message and others accept accent directly. Reserve AccentScope for grouping several children or wrapping ones that don't take the prop.

Source: packages/alouette/src/ui/primitives/Text.tsx, ui/containers/AccentScope.tsx

HIGH Reading a token value in JS instead of applying a className

Wrong:

tsx
import { useUnstableNativeVariable } from "nativewind";
const color = useUnstableNativeVariable("--color-accent");
<Text style={{ color }}>Hi</Text>;

Correct:

tsx
<Text className="text-accent">Hi</Text>

alouette exports no token-read hook (useThemeToken was removed in 21.0.0). Reaching into NativeWind's variable context directly is a native-only path: on web the theme is a className resolved by the palette CSS and nothing populates that context, so the read comes back undefined and the text falls back to an unthemed color. Style with the token className on both platforms.

Source: packages/alouette/src/ui/containers/ScopedTheme.web.tsx, core/AlouetteProvider.web.tsx

MEDIUM Custom palette CSS paired with the default themeVariables

Wrong — importing a generated palette CSS but leaving AlouetteProvider on the default map:

tsx
// global.css imports core.css + ./palette.css, but:
import { themeVariables } from "alouette/defaultThemeVariables";
<AlouetteProvider themeVariables={themeVariables}>
  {/* app */}
</AlouetteProvider>;

Correct:

tsx
import { themeVariables } from "./themeVariables"; // writeTheme output
<AlouetteProvider themeVariables={themeVariables}>
  {/* app */}
</AlouetteProvider>;

A theme has two coupled outputs: the palette CSS (className tokens, the only thing web uses) and the themeVariables map (native token reads — gradients, Switch, placeholderTextColor, SVG tint). The prop is required, so this fails silently rather than loudly, and only on device: the default map type-checks fine, classNames use the custom palette, and the few native reads resolve to the default colors — so native gradients and controls mismatch the rest of the UI while web looks correct. Both must come from the same writeTheme call — that is why it writes the pair.

Source: packages/alouette/src/core/NativeThemeVariablesContext.ts, theme-generator/writeTheme.ts

MEDIUM Expecting var() chains to resolve on native

Wrong:

css
--color-accent: var(--color-brand);

Correct:

css
--color-accent: #2563eb;

On native, NativeWind resolves CSS variables from a lookup table and cannot follow a var() that points at another var(). alouette sub-themes use concrete hex values per mode+accent for this reason.

Source: CLAUDE.md (Native constraint: no CSS variable chains)

MEDIUM Using AccentScope for an accent that toggles at runtime

Wrong:

tsx
<AccentScope accent={hovered ? "danger" : undefined}>
  <RowContent />
</AccentScope>

Correct:

tsx
<StableAccentScope accent={hovered ? "danger" : undefined}>
  <RowContent />
</StableAccentScope>

AccentScope only renders its ScopedTheme wrapper when accent is set — it returns children unwrapped otherwise. Toggling accent therefore mounts or unmounts that wrapper in either direction, remounting the subtree and dropping focus from any input inside. StableAccentScope always keeps ScopedTheme mounted, falling back to the inherited theme when accent is unset, so toggling only changes the theme prop. Prefer AccentScope when the accent is fixed; reach for StableAccentScope only when it toggles.

Source: packages/alouette/src/ui/containers/StableAccentScope.tsx

References

See also: alouette-typography/SKILL.md — color tokens are applied through Text.