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
- AdaptivePanelA detail surface that adapts to width: a BottomSheet on a phone, and a docked side column from ADAPTIVEPANELBREAKPOINT up, displacing the content rather than covering it.
- BadgeA small status or category label.
- BarChartThe native counterpart of the web BarChart, with the same data and colours.
- BottomSheetA sheet that slides up over the screen: rung 5 of the disclosure ladder.
- BottomTabBarAn app's root navigation (the promotion ladder): up to four primary tabs, the rest in a More sheet, with a 2px accent stripe over the active tab.
- ButtonThe native counterpart of the web Button: the same five variants and three sizes, as a TouchableOpacity announced as a button.
- CardA bordered surface: flat or stamp (the hard offset shadow), four padding steps, and an optional header.
- CheckboxA checkbox with an optional label, controlled (checked) or not (defaultChecked), with an indeterminate state.
- ConfirmDialogA promise-based yes/no dialog with the same options as the web useConfirm.
- DisclosureA collapsible section: a labelled toggle with a 44px hit area that animates its content open.
- Form inputsField wraps a control with a label, a hint and an error; Input and Textarea are themed TextInputs that take every TextInput prop, plus error.
- GlyphAny of the 397 glyphs on native, drawn with react-native-svg (Metro needs react-native-svg-transformer; see Setup).
- GlyphPickerA trigger that opens a sheet of every glyph, with search and an eight-column grid.
- MarginaliaA large decorative glyph tucked into a corner of its container, at low opacity.
- MaterialCardA contained surface in the project's material, for objects that arrive: a sheet, a receipt, one featured card.
- ModalA centred dialog, the native counterpart of the web Modal: title, description, a scrollable body and a footer.
- ProgressBarA determinate bar from 0 to 100, in two sizes and two tones.
- ScreenNavThe bar at the top of a pushed or modal screen: a back button, a centred title that truncates to one line, and an optional action.
- SegmentedControlAn inline group where exactly one option is active.
- SkeletonA pulsing placeholder for content that is loading: lines of a given width, or a block.
- SparklineA small trend line, drawn with react-native-svg.
- StatusDotA small filled dot for compact status, in Badge's tones.
- TabsA horizontally scrolling tab bar with an underline on the active tab, and the active tab's content below.
- TagInputTags as chips inside the field, with suggestions.
- TextAll native type: display, heading, body, mono, eyebrow and caption variants, with size, muted and bold adjustments.
- ToastToasts that float over the screen and dismiss themselves.
- ToggleAn animated on/off switch.
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
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.