Skip to contentEnma

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:

sh
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).

tsx
// 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>
  );
}
ThemePackagesFaces
Graphite@expo-google-fonts/inter-tight, jetbrains-monoInterTight 400, 600; JetBrainsMono 500
Glasshanken-groteskHankenGrotesk 400, 600, 700
Paperinstrument-serif, instrument-sansInstrumentSerif 400 and italic; InstrumentSans 400, 600
Mineralsora, manropeSora 600; Manrope 400, 600
Enamelspace-groteskSpaceGrotesk 400, 600, 700
EmbergeistGeist 400, 600

Provider, hook and HeroScope

  • <EnmaProvider theme mode> puts one theme in context. mode is home (default), system, light or dark.
  • useEnma() returns color (by role), radius, shadow, font, num (number face tuning), space and size.
  • <HeroScope> switches ink and ink-2 to 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).

tsx
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.lg cards, md groups, sm inputs, control buttons). Paper stays sharp.
  • Depth: use the theme's shadow.card and shadow.cta strings as boxShadow. Enamel's hard offset shadow works as is. Hairlines use StyleSheet.hairlineWidth with color.line.
  • Glass: surfaces are translucent colors over an expo-blur BlurView (intensity around 40), and the screen background draws the three glows with react-native-svg radial gradients from color['glow-1'] to color['glow-3'].
  • Ember: the warm glow is one react-native-svg radial gradient from color.glow behind 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 with maxFontSizeMultiplier={1.2} so layouts hold.
  • Touch: 44pt minimum for everything tappable; use hitSlop when 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: FlashList for anything that can grow past a screen; images through expo-image.
  • Accessibility: accessibilityRole, accessibilityLabel and accessibilityState on every custom control; announce toasts with AccessibilityInfo.announceForAccessibility; test with VoiceOver, TalkBack and the largest text size.

↑ ↓ to move, Enter to open, Esc to close

Keyboard shortcuts

Open the command palette
Ctrl then K
Search
/
Show keyboard shortcuts
?
Close the top overlay
Esc
Go to Home
G then H
Go to Themes
G then T
Go to Playground
G then P
Go to Components
G then C
Go to Guides
G then G
Go to Samples
G then S