Build with React Native
For Expo and bare React Native apps. CSS variables don't exist on native, so Enma becomes a theme object (from @enma/tokens) plus a provider and a hook, in enma/native. The role names are identical to the web (surface, ink-2, on-primary), so a screen ported from web keeps its meaning.
Assumed stack: Expo SDK with the New Architecture (React Native 0.76 or later, which supports boxShadow and borderCurve), react-native-reanimated, expo-haptics, expo-font with the matching @expo-google-fonts/* packages, @shopify/flash-list and expo-image.
Install
After the one-time setup:
npx expo install react-native-reanimated expo-haptics expo-font @expo-google-fonts/geist
pnpm add enma@npm:enma-design
Fonts per theme
Load only your theme's faces at startup and keep the splash screen until they are ready. Import each weight from its subpath (@expo-google-fonts/geist/600SemiBold); the package index pulls in every weight. FONTS from enma/native names the faces each theme uses, following the @expo-google-fonts convention (Family_600SemiBold).
// app/_layout.tsx (Expo Router)
import { Geist_400Regular } from '@expo-google-fonts/geist/400Regular';
import { Geist_600SemiBold } from '@expo-google-fonts/geist/600SemiBold';
import { useFonts } from 'expo-font';
import { Stack } from 'expo-router';
import * as SplashScreen from 'expo-splash-screen';
import { StatusBar } from 'expo-status-bar';
import { useEffect } from 'react';
import { EnmaProvider } from 'enma/native';
SplashScreen.preventAutoHideAsync();
export default function RootLayout() {
const [loaded] = useFonts({ Geist_400Regular, Geist_600SemiBold });
useEffect(() => {
if (loaded) SplashScreen.hideAsync();
}, [loaded]);
if (!loaded) return null;
return (
<EnmaProvider theme="ember">
<StatusBar style="light" />
<Stack screenOptions={{ headerShown: false }} />
</EnmaProvider>
);
}
| Theme | Packages | Faces |
|---|---|---|
| Graphite | @expo-google-fonts/inter-tight, jetbrains-mono | InterTight 400, 600; JetBrainsMono 500 |
| Glass | hanken-grotesk | HankenGrotesk 400, 600, 700 |
| Paper | instrument-serif, instrument-sans | InstrumentSerif 400 and italic; InstrumentSans 400, 600 |
| Mineral | sora, manrope | Sora 600; Manrope 400, 600 |
| Enamel | space-grotesk | SpaceGrotesk 400, 600, 700 |
| Ember | geist | Geist 400, 600 |
Provider, hook and HeroScope
<EnmaProvider theme mode>puts one theme in context.modeishome(default),system,lightordark.useEnma()returnscolor(by role),radius,shadow,font,num(number face tuning),spaceandsize.<HeroScope>switchesinkandink-2to the hero's own roles.Card variant="hero"applies it for you.
Set the status bar, navigation bar and navigation theme (@react-navigation theme.colors, Expo Router ThemeProvider) from color.bg, color.surface, color.ink and color.line, so system chrome matches.
Components
enma/native ships Button, Stat, Card, Badge, ListGroup and Bars with the same props as the web where it makes sense (title and onPress instead of children and onClick on Button).
import { ScrollView, Text, View } from 'react-native';
import { Bars, Button, Card, ListGroup, Stat, useEnma } from 'enma/native';
export default function Today() {
const enma = useEnma();
return (
<ScrollView style={{ backgroundColor: enma.color.bg }} contentContainerStyle={{ padding: 16, gap: 12 }}>
<View>
<Text style={{ color: enma.color['ink-2'], fontFamily: enma.font.body, fontSize: 12 }}>Thursday</Text>
<Text style={{ color: enma.color.ink, fontFamily: enma.font.display, fontSize: 22 }}>Push day</Text>
</View>
<Card variant="hero" padding="lg">
<Stat size="hero" label="Weekly volume" value={12480} unit="kg" delta="+8%" />
<View style={{ marginTop: 18 }}>
<Bars data={[38, 52, 44, 70, 58, 82, 96]} labels={['M', 'T', 'W', 'T', 'F', 'S', 'S']} />
</View>
</Card>
<ListGroup>
<ListGroup.Row title="Bench press" meta="4 × 8 · 80 kg" />
<ListGroup.Row title="Incline press" meta="3 × 10 · 26 kg" />
</ListGroup>
<Button variant="primary" size="lg" title="Start workout" onPress={() => {}} />
</ScrollView>
);
}
For the rest of the component set, follow the same pattern as Button in the package source: read everything from useEnma(), press with a reanimated spring, and use the web props.
Native rules
- Corners: every rounded view sets
borderCurve: 'continuous'with the theme's radius (radius.lgcards,mdgroups,sminputs,controlbuttons). Paper stays sharp. - Depth: use the theme's
shadow.cardandshadow.ctastrings asboxShadow. Enamel's hard offset shadow works as is. Hairlines useStyleSheet.hairlineWidthwithcolor.line. - Glass: surfaces are translucent colors over an
expo-blurBlurView(intensity around 40), and the screen background draws the three glows withreact-native-svgradial gradients fromcolor['glow-1']tocolor['glow-3']. - Ember: the warm glow is one
react-native-svgradial gradient fromcolor.glowbehind the top of the screen and the hero. - Numbers: always
fontVariant: ['tabular-nums']and the theme's number face; allow Dynamic Type but cap hero numbers withmaxFontSizeMultiplier={1.2}so layouts hold. - Touch: 44pt minimum for everything tappable; use
hitSlopwhen the visual is smaller (Button does). - Motion: presses use reanimated springs on the UI thread; light haptics on primary actions and toggles only; everything checks
useReducedMotion(). - Lists:
FlashListfor anything that can grow past a screen; images throughexpo-image. - Accessibility:
accessibilityRole,accessibilityLabelandaccessibilityStateon every custom control; announce toasts withAccessibilityInfo.announceForAccessibility; test with VoiceOver, TalkBack and the largest text size.