Skip to content

React Native on GTK

Write <View>, <Text> and <Pressable> and get real GTK widgets. @gjsify/react-native is the package a bundler aliases react-native to, so an Expo or React Native app keeps the imports its files were written with. Its view layer is the part that ports, and the aim is that it ports without a rename per file.

This page is for code you already have. If you are writing a GTK app from scratch, the React page carries the other element vocabulary and the comparison between the two.

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 list of what works, readable from your own code, 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 application per process. runApplication builds the GApplication, and a process has one, so a second call while the first is still running is refused by name rather than quietly taking the first one’s window. Await the call, or render both trees under one root component:

TypeScript
async function main(): Promise<void> {
const code = await AppRegistry.runApplication('Counter', { applicationId: 'com.example.Counter' });
return process.exit(code);
}
main();

return process.exit(code), not a bare process.exit(): a bare call leaves the GTK main loop mid-teardown, and you get a core dump where you wanted an exit code.

While it runs, AppRegistry.getApplication() and AppRegistry.getWindow() answer with the Adw.Application and its window. That is how you add an action, set a default size or connect close-request from inside a component. Both answer null before the first render and again once the loop has ended, so an accessor never hands you a window that has closed.

When the window first appears, the layer checks its own chrome and warns if the composition left you no way to close the window, or two close buttons where one is dead. You will see it on stderr, and you can assert the same sentences in a test:

TypeScript
import { lastWindowChromeProblems } from 'react-native';
lastWindowChromeProblems(); // [] once the window has been checked and is well formed

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
ModalAdw.Dialog, presented against the parent rather than placed inside it

A list looks the way it does on a phone:

TypeScript
import { FlatList, Text, View } from 'react-native';
const items = [
{ id: 'a', title: 'Quarterly report' },
{ id: 'b', title: 'Travel expenses' },
];
<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';

A modal is a dialog, and visible is the whole of it

Section titled “A modal is a dialog, and visible is the whole of it”

<Modal> renders an Adw.Dialog, a sheet over the window it is in, with libadwaita’s own dim behind it. It is the one component whose widget is not placed inside its parent: a dialog is presented against a parent, so the box you write it in never holds it and never shifts because of it. Write it where it belongs and drive it with visible:

TypeScript
import { useState } from 'react';
import { Modal, Pressable, Text, View } from 'react-native';
function Notes() {
const [open, setOpen] = useState(false);
return (
<View className="flex-1 p-m">
<Pressable onPress={() => setOpen(true)}>
<Text>Rename…</Text>
</Pressable>
<Modal visible={open} onRequestClose={() => setOpen(false)}>
<View className="p-l gap-m">
<Text className="text-lg">Rename this note</Text>
<Pressable onPress={() => setOpen(false)}>
<Text>Done</Text>
</Pressable>
</View>
</Modal>
</View>
);
}

onRequestClose is required in practice, exactly as React Native documents it for Android and tvOS, and here for a reason you can see: the dialog never closes itself. Escape, the close control and a click on the backdrop all arrive at onRequestClose, and setting visible to false is what takes the sheet down. Leave the prop out and the user cannot dismiss it.

onShow fires once, when the modal is actually on screen. Children go into a box inside the dialog, so several of them are fine. But style and className land on the dialog, which is not a box, so items-* and gap-* there are refused by name and belong on a <View> inside.

Refused, each naming what to do instead: animationType, transparent, backdropColor and presentationStyle (libadwaita animates and dims the sheet itself, and picks the animation from Adw.Dialog:presentation-mode; reach it through a ref if you want a bottom sheet), onDismiss (the only thing that dismisses the dialog is the element being unrendered, so put that work in the effect cleanup of whatever renders the <Modal>), plus allowSwipeDismissal, supportedOrientations, onOrientationChange and modalRef.

For a question with buttons and no layout of your own, Alert.alert() is the shorter answer: it is an Adw.AlertDialog and needs no element at all.

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 shows the clamp, the header bar and the toolbar view from every other angle: the live widget, its markup, the GJS program, the Blueprint declaration and the NativeScript template. It carried a tab for this pairing too, on those three blocks and nowhere else, which is why the pairing is documented here instead.

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.

The gallery used to make that claim in a tab label, which read React Native on GTK rather than React Native, because a plain one beside the NativeScript tab would have been taken for the port that runs on a device. The tab is gone and the qualifier is this page’s title now, because the label went and its reason did not. Worth knowing what that reason is not: it was once written down as there is no Adwaita widget set built out of React Native primitives, so nothing here runs on a phone, and #1380 wrote exactly such a set. The premise died and the conclusion held, because what settles it is the direction below and not what exists elsewhere in the tree.

The distinction 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. Adw.Bin and Adw.Clamp 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. You write import { Adw } from '@gjsify/adwaita-react-native' and then <Adw.Clamp>: the member is libadwaita’s own type name with its namespace prefix read back off it, and it is the only spelling the package root has.

What it is honest about is more useful than what it claims. The phone half is no 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, so its numbers are asserted as instructions rather than measured as pixels, because there is no Yoga and no phone in the loop. 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 Adw.Clamp 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 expo-router 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.

Deep links land where you send them, including a tab that is not the first one. A desktop application gets them the way a phone does, through a URI handler, a notification or a restored session. Entering at /settings rather than at the index route is the ordinary path, not an edge case:

TypeScript
import { router } from '@gjsify/react-native/router';
// Whatever handed you the entry point: a URI handler, a `Gio.SimpleAction`,
// or your own restore logic.
const entryHref = '/settings';
router.navigate(entryHref);

The tab the URL names is the tab that is focused and the tab the switcher highlights. A tabPress event is emitted only for a real press, so a handler hung off it, usually “scroll to top on the second press”, does not fire while the application is being entered.

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() and setSelection(). It also carries 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

A prop can be answered and still refuse some of its VALUES. accessibilityRole is the one that does: most of React Native’s role names map onto a GTK accessible role, and a handful name an Android class or an iOS trait that has no counterpart at all. Ask about the value:

TypeScript
import { acceptsPropValue, explainPropValue, propRefusedValues } from '@gjsify/react-native/prop-table';
acceptsPropValue('View', 'accessibilityRole', 'button'); // true
acceptsPropValue('View', 'accessibilityRole', 'keyboardkey'); // false
explainPropValue('View', 'accessibilityRole', 'keyboardkey'); // what to write instead
propRefusedValues('View', 'accessibilityRole'); // every value refused by name

explainProp and explainPropValue return 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, with the refused values and their reasons under the row they belong to.

  • Getting started if you do not have a gjsify project yet
  • React for the choice between the two element vocabularies, the adapter underneath, and the build flags in full
  • Styling on GTK for the class families and token scales
  • Package README for the generated per-name support listing
  • rn-design-system for a design-system layer rendered unmodified into an Adwaita window
  • How the React layers work for the measurement behind each rule about the primitives, the lists and the router