AcheryWorkshop

React Native

achery-ui/native is the same design system for React Native: the same tokens and theme axes, with components built on React Native primitives. The examples on these pages render through react-native-web in the platform font; an app opts into the brand fonts with the provider's fonts prop (see below).

Components

A subset of components available for React Native via achery-ui/native. Uses React Native's StyleSheet system and SemanticTokens directly — no vanilla-extract, no CSS.

Live examples of each, rendered through react-native-web: workshop.achery.dev/native.

Setup

# Required native peer deps
pnpm add react-native-svg
pnpm add -D react-native-svg-transformer

metro.config.js — add the transformer and force registry singletons:

const { getDefaultConfig } = require('expo/metro-config')
const path = require('path')
const config = getDefaultConfig(__dirname)

// Singleton modules — must resolve from one location only.
// react-native's view config registry must be a singleton or native component
// registrations (e.g. RNSVGCircle) land in a different Map than the renderer uses.
const singletonDir = path.resolve(__dirname, 'node_modules')
const singletonModules = new Set([
  'react',
  'react-native',
  'react-native/Libraries/Renderer/shims/ReactNativeViewConfigRegistry',
  'react-native/Libraries/NativeComponent/NativeComponentRegistry',
])
config.resolver.resolveRequest = (context, moduleName, platform) => {
  if (singletonModules.has(moduleName)) {
    return context.resolveRequest(
      { ...context, originModulePath: path.join(singletonDir, 'react-native', 'index.js') },
      moduleName, platform,
    )
  }
  return context.resolveRequest(context, moduleName, platform)
}

// SVG transformer — required for Glyph component
config.transformer = {
  ...config.transformer,
  babelTransformerPath: require.resolve('react-native-svg-transformer/expo'),
}
config.resolver = {
  ...config.resolver,
  assetExts: config.resolver.assetExts.filter(ext => ext !== 'svg'),
  sourceExts: [...config.resolver.sourceExts, 'svg'],
}
module.exports = config

.svgrrc (in your app root) — maps currentColor to the color prop:

{ "replaceAttrValues": { "currentColor": "{props.color}" } }

Accessibility

Interactive components announce a role and state to VoiceOver / TalkBack (buttons, tabs with selected, disabled states, a header for ScreenNav's title), and icon-only controls inside the library carry their own names (Toast "Dismiss", TagInput "Remove " / "Add ").

An icon-only Button has no visible text to read, so name it:

<Button variant="ghost" size="sm" onPress={skip} accessibilityLabel="Skip">
  <Glyph name="close" size={12} />
</Button>

Native Button accessibility props:

Prop Type Default
accessibilityLabel string — (read from text children)
accessibilityHint string —
testID string —

A Glyph with an accessibilityLabel is announced as an image with that name; without one it is decorative. Label a glyph only when it carries meaning on its own — inside a labelled Button, the button's label is enough.

These also make components findable in tests by role and name (getByRole('button', { name: 'Skip' })).

NativeThemeProvider / useTheme

Mirrors the web AcheryProvider contract: a three-way colour-mode preference (light / dark / system), an accent colour, and the dial/material signatures. 'system' follows the OS appearance live.

import { NativeThemeProvider, useTheme } from 'achery-ui/native'
import AsyncStorage from '@react-native-async-storage/async-storage'

<NativeThemeProvider defaultMode="system" defaultAccent="moss" storage={AsyncStorage}>
  <App />
</NativeThemeProvider>

// anywhere below
const { mode, setTheme, theme, dark, accent, setAccent } = useTheme()
setTheme('system')        // preference — may be 'system'
theme                     // resolved — always 'light' | 'dark'

Persistence is opt-in. React Native has no built-in synchronous key-value store and achery-ui will not depend on one — pass any adapter with getItem/setItem (expo-secure-store, AsyncStorage, MMKV…). Omit storage and the provider is in-memory only, as before. Keys match the web provider's (achery-theme-mode, achery-accent, achery-dial, achery-material).

Because native storage is async, stored values load after first paint: the provider renders with the default* props, then swaps once loaded. A preference the user changes before hydration completes is never clobbered. Read hydrated to hold a splash screen and avoid a light→dark flash.

Prop Type Default
defaultMode 'light' | 'dark' | 'system' 'system'
defaultDark boolean false — deprecated, use defaultMode
defaultAccent AccentColor 'terracotta'
defaultDial AccentDial 'chrome'
defaultMaterial MaterialSignature 'none'
defaultSurfaceOrigin SurfaceOrigin 'native-only'
storage ThemeStorage — (no persistence)
fonts NativeFonts — (platform font)

Brand fonts (opt-in). Native text uses the platform font unless the app loads the fonts and maps them. React Native picks weights by family name, so map each weight you load:

import { useFonts, RobotoSlab_700Bold, RobotoSlab_900Black } from '@expo-google-fonts/roboto-slab'
// …SpaceGrotesk_400Regular, _500Medium, _600SemiBold, _700Bold; JetBrainsMono_400Regular

<NativeThemeProvider
  fonts={{
    display: { bold: 'RobotoSlab_700Bold', black: 'RobotoSlab_900Black' },
    body: { regular: 'SpaceGrotesk_400Regular', medium: 'SpaceGrotesk_500Medium',
            semibold: 'SpaceGrotesk_600SemiBold', bold: 'SpaceGrotesk_700Bold' },
    mono: { regular: 'JetBrainsMono_400Regular' },
  }}
>

A role and weight left out falls back to the platform font at that weight. Every native component sets its text through useTheme().font(role, weight), which custom components can use too.

useTheme() returns:

Key Type Notes
tokens SemanticTokens Resolved colour tokens for the active mode + accent
mode ThemeMode The preference — may be 'system'
theme 'light' | 'dark' The resolved mode actually rendered
setTheme (mode: ThemeMode) => void Persisted when storage is given
dark boolean theme === 'dark'
toggle () => void Flips to explicit light/dark (resolves 'system' first)
accent / setAccent AccentColor Persisted
dial / setDial AccentDial Persisted
material / setMaterial MaterialSignature Persisted
surfaceOrigin SurfaceOrigin Set once on the provider
hydrated boolean False until storage has been read; always true without storage
font (role, weight?) => FontStyle { fontFamily } from fonts, or { fontWeight } for the platform font

The dial is stored and exposed here, but native components don't change with it yet.

dark and toggle are unchanged from earlier versions — existing code keeps working.