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
Section titled “Install”npm install -D @gjsify/react-native react react-reconciler @types/reactyarn add -D @gjsify/react-native react react-reconciler @types/reactpnpm add -D @gjsify/react-native react react-reconciler @types/reactbun add --dev @gjsify/react-native react react-reconciler @types/reactdeno add --dev npm:@gjsify/react-native npm:react npm:react-reconciler npm:@types/reactgjsify install @gjsify/react-native react react-reconciler @types/reactPin 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.
Declare your tokens
Section titled “Declare your tokens”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:
// 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:
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:
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.
Write a screen
Section titled “Write a screen”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.
Start the app
Section titled “Start the app”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.
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:
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:
import { lastWindowChromeProblems } from 'react-native';
lastWindowChromeProblems(); // [] once the window has been checked and is well formedBuild it
Section titled “Build it”One flag turns an ordinary build into a React Native port:
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:
{ "gjsify": { "dialect": "react-native" }}What renders
Section titled “What renders”| Component | Becomes |
|---|---|
View | Gtk.Box, or Gtk.Overlay when a child is absolutely positioned |
Text | Gtk.Label |
Pressable, TouchableOpacity, TouchableHighlight | Gtk.Button, flat |
TouchableWithoutFeedback | Gtk.Box with a click gesture, no button chrome |
Button | Gtk.Button |
ScrollView | Gtk.ScrolledWindow with a content box |
FlatList, SectionList, VirtualizedList | Gtk.ListView over a list store |
Image | Gtk.Picture |
ImageBackground | Gtk.Picture in a Gtk.Overlay |
TextInput | Gtk.Entry, or Gtk.TextView with multiline |
Switch | Gtk.Switch |
ActivityIndicator | Adw.Spinner |
SafeAreaView, KeyboardAvoidingView | Gtk.Box, since a window has neither notch nor soft keyboard |
Modal | Adw.Dialog, presented against the parent rather than placed inside it |
A list looks the way it does on a phone:
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:
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:
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.
Adwaita widgets
Section titled “Adwaita widgets”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:
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.
slotis 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 isstart. - Layout that needs a parent needs a
<View>above it.flex-1,self-*andabsoluteresolve 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,appendandset_childall 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. CalltableProvenance()from@gjsify/gtk-hostfor 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.
What this is not
Section titled “What this is not”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.
Two defaults that are inverted
Section titled “Two defaults that are inverted”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.
Anything missing tells you what it is
Section titled “Anything missing tells you what it is”Importing a name that is not built does not fail with a spelling error. It gives you the reason:
@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 doingit. 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.
Routing
Section titled “Routing”@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:
(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 routeA 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:
// build configimport { rnRouteManifestPlugin } from '@gjsify/rolldown-plugin-gjsify';
rnRouteManifestPlugin({ routesDir: 'app' });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:
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.
Styling
Section titled “Styling”className works, over the same class families the styling page
documents. The values come from your project, and you install them once:
import { configureStyle } from '@gjsify/react-native';import { tokens } from './tokens.js';
configureStyle({ tokens });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.
Where a component refuses a prop
Section titled “Where a component refuses a prop”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.
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} />;}Ask before you render
Section titled “Ask before you render”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:
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 thrownacceptsProp('Pressable', 'onPress'); // trueA 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:
import { acceptsPropValue, explainPropValue, propRefusedValues } from '@gjsify/react-native/prop-table';
acceptsPropValue('View', 'accessibilityRole', 'button'); // trueacceptsPropValue('View', 'accessibilityRole', 'keyboardkey'); // falseexplainPropValue('View', 'accessibilityRole', 'keyboardkey'); // what to write insteadpropRefusedValues('View', 'accessibilityRole'); // every value refused by nameexplainProp 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.
Related
Section titled “Related”- 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-systemfor 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