Skip to content

React Native

Write <View>, <Text> and <Pressable> and get real GTK widgets. If you already have an Expo or React Native app, its view layer is the part that ports, and the aim is that it ports without a rename per file.

There is no bridge and no native module host. This renders in-process onto GTK, so NativeModules, TurboModuleRegistry and findNodeHandle are not available and say so when you import them. What you get instead is a checkable list of what works, and every gap named before you run the app rather than found in a window that looks plausible.

Install
Terminal
npm install -D @gjsify/react-native react react-reconciler @types/react
Terminal
yarn add -D @gjsify/react-native react react-reconciler @types/react
Terminal
pnpm add -D @gjsify/react-native react react-reconciler @types/react
Terminal
bun add --dev @gjsify/react-native react react-reconciler @types/react
Terminal
deno add --dev npm:@gjsify/react-native npm:react npm:react-reconciler npm:@types/react
Terminal
gjsify install @gjsify/react-native react react-reconciler @types/react

Pin React 19, as the React page explains. For routing you also need @react-navigation/core and @react-navigation/routers, which are peers rather than bundled copies.

className works on every primitive, and the values behind those names are your project’s. The layer ships a deliberately tiny default scale — 0 and px, and nothing between them — so that a class naming a token you have not declared is a named error rather than a wrong margin. Write the scales once:

TypeScript
// tokens.ts, generated from your design-token source or written by hand.
import type { StyleTokens } from '@gjsify/gtk-host/style';
export const tokens: StyleTokens = {
spacing: { '0': '0px', px: '1px', xs: '4px', s: '8px', m: '12px', l: '16px', xl: '24px' },
fontSize: { caption: '11px', body: '14px', title: '18px', display: '26px' },
};

…and install them before the first render:

TypeScript
import { configureStyle } from '@gjsify/react-native';
import { tokens } from './tokens.js';
configureStyle({ tokens });

If your tokens are generated from a Tailwind v4 @theme, add the structural defaults your @theme has no reason to declare — measured, a real application’s vocabulary loses rounded-full and inset-0/top-0/left-0/right-0 without them:

TypeScript
import { TAILWIND_DEFAULT_TOKENS, mergeTokens } from '@gjsify/gtk-host/style';
configureStyle({ tokens: mergeTokens(TAILWIND_DEFAULT_TOKENS, tokens) });

mergeTokens and not a spread: a spread replaces whole scales and loses 0 again. The styling page has the numbers and what the set does not carry.

Every snippet on this page uses those two scales. Skipping this step is not a styling difference, it is a blank window: the first undeclared token throws out of the render, React unmounts the tree, and the process goes on to exit 0 with no GTK diagnostic at all — measured, and the reason shotEvidence exists. The styling page has the whole class vocabulary; the token names above are only what these examples need.

TypeScript
import { View, Text, Pressable } from 'react-native';
export function Counter({ count, onPress }: { count: number; onPress: () => void }) {
return (
<View>
{/* `flex-1` resolves against the PARENT's orientation, so it goes on a
child and not on the root of the tree — the root has no parent to
resolve against, and the layer says so instead of guessing. Same rule
for `self-*` and `absolute`; see "Where a component refuses a prop". */}
<View className="flex-1 items-center justify-center gap-m">
<Text className="text-title">{count}</Text>
<Pressable onPress={onPress}>
<Text>Add one</Text>
</Pressable>
</View>
</View>
);
}

That is an ordinary React Native component. The import says react-native because the build aliases it, so the file is the same one your phone app ships.

AppRegistry creates the application and its window, which is the one thing a phone host never has to ask for: on a desktop, your app is the process, so it needs a GApplication id.

TypeScript
import { AppRegistry } from 'react-native';
import { Counter } from './Counter.js';
AppRegistry.registerComponent('Counter', () => Counter);
AppRegistry.runApplication('Counter', { applicationId: 'com.example.Counter' });

Expo’s registerRootComponent works too and takes the same options.

One flag turns an ordinary build into a React Native port:

Terminal
gjsify build src/app.tsx --app gjs --outfile dist/app.gjs.mjs \
--dialect react-native \
--define 'process.env.NODE_ENV="production"' \
--exclude-globals navigator

--dialect react-native does two things. It aliases react-native to @gjsify/react-native, so your files import the name they already import. And it fails the build on an import of a name that is not available, telling you the file and the line before anything runs.

The other two flags are React’s, and the React page says what they do.

You can set it in package.json instead:

JSON
{
"gjsify": { "dialect": "react-native" }
}
ComponentBecomes
ViewGtk.Box, or Gtk.Overlay when a child is absolutely positioned
TextGtk.Label
Pressable, TouchableOpacity, TouchableHighlightGtk.Button, flat
TouchableWithoutFeedbackGtk.Box with a click gesture, no button chrome
ButtonGtk.Button
ScrollViewGtk.ScrolledWindow with a content box
FlatList, SectionList, VirtualizedListGtk.ListView over a list store
ImageGtk.Picture
ImageBackgroundGtk.Picture in a Gtk.Overlay
TextInputGtk.Entry, or Gtk.TextView with multiline
SwitchGtk.Switch
ActivityIndicatorAdw.Spinner
SafeAreaView, KeyboardAvoidingViewGtk.Box, since a window has neither notch nor soft keyboard

A list looks the way it does on a phone:

TypeScript
import { FlatList, Text, View } from 'react-native';
<FlatList
data={items}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<View className="p-m">
<Text>{item.title}</Text>
</View>
)}
/>;

Plus the APIs: Linking, Platform, Share, useColorScheme, StyleSheet, Dimensions, useWindowDimensions, Alert, Appearance, Keyboard, StatusBar, EventEmitter and AppRegistry.

The per-name list lives in the package, not here. Each name carries a status and a reason, and the package README is generated from that data, so it cannot drift. You can read the same data from your own code:

TypeScript
import { SUPPORT_TABLE, isImportable, explainUnsupported } from '@gjsify/react-native';

The table above reaches libadwaita exactly once, for Adw.Spinner, and it names no Adwaita container at all. That is the honest shape of this layer: it maps React Native’s primitives onto GTK, and React Native has no clamp, no header bar and no preferences group to map.

You still get them, from the other side. @gjsify/gtk-host carries a generated tag for every concrete widget in Gtk and Adw, and pointing jsxImportSource at @gjsify/gtk-host/react — the same tsconfig a React app on GTK already uses — is what puts that tag list in scope. So an Adwaita container and a React Native primitive live in one tree:

TypeScript
import { Pressable, Text, View } from 'react-native';
export function Documents({ onAdd }: { onAdd: () => void }) {
return (
<adw-toolbar-view>
<adw-header-bar slot="top">
<adw-window-title slot="title" title="Documents" subtitle="12 items" />
</adw-header-bar>
<View className="flex-col gap-s p-xl">
<Text className="text-title">Your library</Text>
</View>
<gtk-box slot="bottom" cssClasses={['toolbar']}>
<Pressable onPress={onAdd}>
<Text>Add</Text>
</Pressable>
</gtk-box>
</adw-toolbar-view>
);
}

Three rules hold there, and the layer states each of them by refusing rather than by dropping:

  • A React Native primitive goes in the container’s default slot. slot is not one of its props, and an unlisted prop is refused by name — so a named slot (top, bottom, title, end) takes a gtk-host tag instead. A toolbar view’s default slot is its content; a header bar’s is start.
  • Layout that needs a parent needs a <View> above it. flex-1, self-* and absolute resolve against the parent’s orientation, and an Adwaita container publishes none — so an element directly under one is the root of its React tree and is told so.
  • The container needs a curated child policy. gtk-host will not guess how a widget adopts a child: add, append and set_child all exist somewhere in GTK and the wrong one is a warning at exit 0. 39 of the 168 tags carry such a rule, among them toolbar view, header bar, clamp, both split views, status page, navigation view, preferences page and action row. Wrap box and action bar do not, and refuse a child by name until someone writes their policy. Call tableProvenance() from @gjsify/gtk-host for the live figure; this count has now drifted three times.

The Layout page of the Adwaita gallery carries this as a React Native on GTK tab beside the GJS, Blueprint, web and NativeScript ones — on the clamp, the header bar and the toolbar view, and not on the wrap box, whose policy is still missing.

This layer does not put Adwaita on a phone. <adw-toolbar-view> above is Adw.ToolbarView — a GTK 4 widget, in a GTK 4 window, on a machine where libadwaita is installed. Nothing on this page runs on Android or iOS, and no tab in the gallery claims otherwise.

That is the distinction worth keeping, and it is a direction rather than a boundary: this layer takes React Native’s components to GTK. The opposite direction — Adwaita’s components rebuilt on React Native’s primitives — has been started, as @gjsify/adwaita-react-native, and it is a walking skeleton rather than a way to run an Adwaita app on a phone.

Every widget it ships carries the whole vertical — both platform halves, the resolution mechanism, the manifest declaration, a gate and suites on each side. AdwBin and AdwClamp came first and proved that shape; the package’s own README table is the live set, and check-vocabulary-alignment.mjs prints the count on every run. The halves fork at the package’s exports map on the react-native condition, never by file name.

What it is honest about is more useful than what it claims. The phone half is not a maxWidth approximation — it runs @gjsify/adwaita-core’s port of adw_clamp_layout_allocate, libadwaita’s own easing curve — but it has never run on a device: its numbers are asserted as instructions, not measured as pixels, because there is no Yoga and no phone in the loop. And its divergences are named in its README rather than smoothed over, the first of them on the very widget above: React Native has no measure pass, so AdwClamp passes the child’s intrinsic minimum as 0, and a child wider than the clamp is compressed there where GTK would widen the clamp instead.

It is on npm (since 0.44.0), but no gallery tab renders it — so nothing you see on this site is that package.

A React Native View is a column and a Gtk.Box is horizontal. A React Native Text wraps and a Gtk.Label does not. Both are set explicitly, so your layouts keep the React Native behaviour. Worth knowing because the failure would have been silent: every widget present, every row a column.

Importing a name that is not built does not fail with a spelling error. It gives you the reason:

Code
@gjsify/react-native: "Animated" is not implemented yet (tier P3). Genuinely mappable,
but it is a subsystem rather than a component. Doing it badly is worse than not doing
it. The GTK counterpart is Adw.TimedAnimation / Adw.SpringAnimation.

With --dialect react-native you get that at build time. Without it you get it the first time the code path runs.

@gjsify/react-native/router mirrors the expo-router surface name for name. Import from the subpath: --dialect react-native rewrites react-native and nothing else, so an expo-router specifier stays an expo-router specifier. router, usePathname, useLocalSearchParams, Stack and Tabs are implemented.

File-based routes follow four conventions:

Code
(group) groups without adding a URL segment
[param] a dynamic segment, readable via useLocalSearchParams()
_layout owns its directory and renders the navigator
+not-found the fallback route

A directory without a _layout is not a navigator: its routes flatten into the nearest ancestor that is one. So detail/[id].tsx needs no detail/_layout.tsx.

Add the manifest plugin to your build and hand the manifest to the root:

TypeScript
// build config
import { rnRouteManifestPlugin } from '@gjsify/rolldown-plugin-gjsify';
rnRouteManifestPlugin({ routesDir: 'app' });
TypeScript
import { AppRegistry } from 'react-native';
import { RouterRoot } from '@gjsify/react-native/router';
import { manifest } from 'virtual:gjsify-rn-routes';
AppRegistry.registerComponent('App', () => () => <RouterRoot manifest={manifest} />);

<Stack> renders onto Adw.NavigationView, so you get the platform’s own back gesture, Escape to go back and the animated push and pop for free. <Tabs> renders onto Adw.ViewStack behind an Adw.ViewSwitcher: adding a route file adds a button, the label comes from the page title, and a screen reader announces the same text a user sees.

Screen options are title, headerShown and animation on <Stack.Screen>, and title on <Tabs.Screen>. Anything else is refused by name.

Router errors carry a stable code, so a test can assert one instead of matching a message: duplicate-route, param-without-name, unresolved-href, not-a-screen-child, unknown-screen-option, no-router-mounted.

navigationRef is exported too. When router does not spell what you need, reach for React Navigation’s own handle rather than a thinner copy of it.

className works, over the same class families the styling page documents. The values come from your project, and you install them once:

TypeScript
import { configureStyle } from '@gjsify/react-native';
import { tokens } from './tokens.js';
configureStyle({ tokens });
TypeScript
import { StyleSheet, View } from 'react-native';
const styles = StyleSheet.create({
card: { padding: 12, borderRadius: 9 },
});
<View style={styles.card} />;

StyleSheet.create and inline style objects go through the same partition as className.

Components marked partial name the props they do not take, in the table, with what to use instead. They share one shape worth carrying with you: React Native puts the thing on the element as data, and GTK puts it in a second object. Allocation arrives through a size-allocate override, focus and long press are event controllers on the widget, a multiline TextInput’s text lives in a Gtk.TextBuffer, and press state is a CSS pseudo-class that never reaches React.

So a refusal points at the GTK object to reach through a ref, rather than at a to-do item.

A <TextInput> ref is React Native’s own imperative handle — focus(), blur(), clear(), isFocused(), setSelection() — plus widget, the Gtk.Entry or Gtk.TextView itself, which is where everything the handle does not answer lives. Every other primitive’s ref is the Gtk.Widget directly.

TypeScript
import { useEffect, useRef } from 'react';
import { TextInput } from 'react-native';
import type Gtk from '@girs/gtk-4.0';
function Notes() {
const input = useRef<TextInput>(null);
useEffect(() => {
input.current?.focus();
// multiline text lives in the buffer, not in `value` — reach the widget for it
(input.current?.widget as Gtk.TextView | undefined)?.get_buffer().set_text('hello', -1);
}, []);
return <TextInput multiline ref={input} />;
}

A refused prop throws when the element renders, and a render-time throw with no error boundary above it takes the whole React tree with it — that is React’s behaviour rather than this layer’s, and it has cost a real application every screen it had. So the prop answers are published too, and you can assert them in your own test suite before a window exists:

TypeScript
import { acceptsProp, explainProp } from '@gjsify/react-native/prop-table';
acceptsProp('Text', 'onPress'); // false — a Gtk.Label emits no `clicked`
explainProp('Text', 'onPress'); // the exact sentence a render would have thrown
acceptsProp('Pressable', 'onPress'); // true

explainProp returns the render’s own message rather than a paraphrase of it, and PROPS.md is the same data as a document, one section per primitive.