UI Frameworks
Write your GTK 4 window in Solid, Vue or React, and get real Gtk and Adw widgets.
GTK can already describe a window rather than assemble it. That is what Blueprint is for, and
it does property bindings too. What a template cannot do is change its own shape. A .blp is
instantiated once, so anything that appears, disappears or reorders while the app runs goes
back into imperative code.
Keeping a tree in sync with your state is what a UI framework is for. Solid, Vue and React can all render somewhere other than a browser. They need an object model to build against, and GTK’s is not the DOM.
@gjsify/gtk-host is that object model.
Nothing is hidden: every tag names a real GTK class, and you mount into a window your
application already built.
Install
Section titled “Install”npm install -D @gjsify/gtk-hostyarn add -D @gjsify/gtk-hostpnpm add -D @gjsify/gtk-hostbun add --dev @gjsify/gtk-hostdeno add --dev npm:@gjsify/gtk-hostgjsify install @gjsify/gtk-hostYour framework is a peer dependency and stays yours: solid-js, @vue/runtime-core, or
react + react-reconciler. Install only the one you use.
One host, three adapters
Section titled “One host, three adapters”| Adapter | Import | Framework contract | Compile step |
|---|---|---|---|
| Solid | @gjsify/gtk-host/solid | solid-js/universal, 10 methods | @gjsify/rolldown-plugin-solid |
| Vue | @gjsify/gtk-host/vue | @vue/runtime-core, 10 required + 4 optional | @gjsify/rolldown-plugin-vue |
| React | @gjsify/gtk-host/react | react-reconciler HostConfig | none, TypeScript’s own automatic runtime does it |
Pick one and the rest of this page is the same for all three. All three speak GTK’s own tag
vocabulary: <gtk-box orientation="vertical">, GTK property names, css-classes as a string
array.
React reaches this host through two element vocabularies rather than one, the GTK tags above and React Native’s primitives. The React page puts the two side by side and says how to pick; the section below is the short version.
None of the code on this page names a runtime. The host is plain TypeScript over gi://, so
every example here builds and runs on GJS, Node.js, Bun and Deno from one source.
Runtimes has that story, along with the two further targets the same widget
names reach: a browser build, and an experimental NativeScript one for Android and iOS.
Bringing a React Native app instead
Section titled “Bringing a React Native app instead”<View>, <Text>, <Pressable> and className="flex-1 items-center" are a different
vocabulary, and it renders over the same host rather than beside it. It is not a fourth adapter.
React Native code goes through the React adapter above, with different leaves and a different
layout model:
- React Native puts React Native’s exports on GTK 4. It is
the package a bundler aliases
react-nativeto. Every name it does not implement is still exported and refuses with a status and a reason, so you read a divergence instead of finding it in a window. - Styling on GTK is
@gjsify/gtk-host/style, the framework-agnostic half of that.class="flex-1"in a Vue template is the same question asclassName="flex-1"on a React element, which is why it is a subpath of the host rather than part of the React Native package. That page also carries the traps that make GTK styling fail silently, each one measured.
Solid JSX
Section titled “Solid JSX”registerBuiltinWidgets() loads the generated table, and after that the tags are available.
Build with @gjsify/rolldown-plugin-solid, which turns the JSX
into calls against the host.
import Adw from 'gi://Adw?version=1';import { createRoot, createSignal } from 'solid-js';
import { registerBuiltinWidgets, type HostNode } from '@gjsify/gtk-host';import { For, widgetOf } from '@gjsify/gtk-host/solid';
registerBuiltinWidgets();
function Counter(app: Adw.Application) { const [count, setCount] = createSignal(0); const [rows, setRows] = createSignal<readonly string[]>([]);
return ( <adw-application-window title="Counter" defaultWidth={480} application={app}> <adw-toolbar-view> <adw-header-bar slot="top" /> <adw-preferences-page slot="content"> <adw-preferences-group title="Rows"> <For each={rows()}>{(row) => <adw-action-row title={row} />}</For> <adw-action-row title="Clicks" subtitle={String(count())} /> </adw-preferences-group> <adw-preferences-group title="Actions"> <gtk-box orientation="vertical" spacing={12}> <gtk-button label="Increment" onClicked={() => setCount((n) => n + 1)} /> <gtk-button label="Add row" onClicked={() => setRows((r) => [...r, `Row ${r.length + 1}`])} /> </gtk-box> </adw-preferences-group> </adw-preferences-page> </adw-toolbar-view> </adw-application-window> ) as HostNode;}
const app = new Adw.Application({ application_id: 'org.example.Counter' });app.connect('activate', () => { const node = createRoot(() => Counter(app)); (widgetOf(node) as Adw.ApplicationWindow).present();});await app.runAsync([]);createRoot rather than bare JSX: every {count()} compiles to a computation, and a
computation created without an owner is never disposed. Solid says so on stderr.
widgetOf(node) hands you the real widget behind a host node. It lives in the host and every
adapter re-exports it, so the call is the same whichever one you are on.
Import For, Index, Show and Dynamic from the adapter, never from solid-js/web.
That package is Solid’s DOM renderer; its components build DOM elements nothing here can
place, and the measured result is a subtree that renders nothing, silently, at exit 0.
Vue single-file components
Section titled “Vue single-file components”Vue mounts into a container, and a toplevel window is not a child of anything. So the
application owns the window and Vue owns everything inside it, which is the shape
mount(rootComponent, container) documents.
<script setup lang="ts">import { ref } from '@vue/runtime-core';
const count = ref(0);const rows = ref<readonly string[]>([]);
const increment = () => { count.value += 1; };const addRow = () => { rows.value = [...rows.value, `Row ${rows.value.length + 1}`]; };</script>
<template> <adw-toolbar-view> <adw-header-bar slot="top"> <GtkLabel label="Counter" slot="title" /> </adw-header-bar> <adw-preferences-page slot="content"> <adw-preferences-group title="Rows"> <adw-action-row v-for="row in rows" :key="row" :title="row" /> <adw-action-row title="Clicks" :subtitle="String(count)" /> </adw-preferences-group> <adw-preferences-group title="Actions"> <gtk-box orientation="vertical" :spacing="12"> <gtk-button label="Increment" @clicked="increment" /> <gtk-label v-if="count > 0" :label="`clicked ${count}x`" /> <gtk-button label="Add row" @clicked="addRow" /> </gtk-box> </adw-preferences-group> </adw-preferences-page> </adw-toolbar-view></template>import Adw from 'gi://Adw?version=1';
import { registerBuiltinWidgets } from '@gjsify/gtk-host';import { mount } from '@gjsify/gtk-host/vue';
import App from './App.vue';
registerBuiltinWidgets();
const app = new Adw.Application({ application_id: 'org.example.Counter' });app.connect('activate', () => { const window = new Adw.ApplicationWindow({ application: app, title: 'Counter', default_width: 480 }); mount(App, window); window.present();});await app.runAsync([]);The Vue adapter exports render, createApp, mount, adopt and widgetOf. You reach for
the last one less often here than in Solid or React. You already hold the window you mounted
into, and a ref on an element gives you a widget inside the tree, as in any Vue app.
widgetOf is for the case where what you hold is a host node rather than a component
instance.
Both tag spellings work in a template. <GtkLabel> and <gtk-label> resolve to the same
widget and to the same type-check key, so pick one and stay with it. The build needs
@gjsify/rolldown-plugin-vue and four --define flags; that page
has the recipe and the reason.
React needs no compile plugin. Its JSX is a runtime, and the adapter ships the automatic
runtime TypeScript expects. Point jsxImportSource at the subpath, not at react:
{ "jsx": "react-jsx", "jsxImportSource": "@gjsify/gtk-host/react", "noImplicitAny": true}import Adw from 'gi://Adw?version=1';
import { createRoot } from '@gjsify/gtk-host/react';
const myWindow = new Adw.ApplicationWindow({ title: 'Counter', default_width: 480 });
const root = createRoot(myWindow);root.render( <gtk-box orientation="vertical"> <gtk-label label="hi" /> </gtk-box>,);root.render() is synchronous on purpose: with no main loop running yet nothing drives the
scheduler, so an unflushed first render would leave the container empty and say nothing.
Pointing jsxImportSource at the subpath rather than at react is what keeps every DOM tag
@types/react pre-declares from type-checking clean against a GTK renderer. The build needs two
flags instead of a plugin, --define 'process.env.NODE_ENV="production"' and
--exclude-globals navigator. The React page has both, with the
reason each one exists.
What the host refuses to do quietly
Section titled “What the host refuses to do quietly”GTK’s failure mode is exit 0. A renderer that forwards authored values verbatim produces a wrong window and a green test run, so the host is loud instead. Measured on gjs 1.88.1:
| What you write | What GObject does | What the host does |
|---|---|---|
orientation="vertical" | keeps HORIZONTAL; the JS setter says nothing at all | resolves the nick against the enum’s GType |
orientation="sideways" | the same silence | throws, naming GtkOrientation |
| a read-only property | accepts the write, stores nothing | throws, naming the property |
| a misspelled property | nothing | throws, naming the widget |
text inside <gtk-image> | nothing | throws, naming the tag and where the text could go |
| a child under a childless widget | Gtk-WARNING at exit 0 | throws, naming the three fixes |
selectable="false" as a string | JS truthiness makes it true | honours 'true'/'false', throws on anything else |
Values are coerced against the GParamSpec read from the installed GTK, so the table
says a property exists and the ParamSpec says what a value may look like.
Where a child goes
Section titled “Where a child goes”GTK 4 deleted GtkContainer, so there is no generic add. Each container’s adoption rule is
data, declared once and shared by all three adapters:
| Kind | Example | How a child lands |
|---|---|---|
single | AdwBin, GtkWindow | set_child / set_content |
ordered | GtkBox | append + insert_child_after |
indexed | GtkListBox | insert(row, i), addressing a wrapper row |
slotted | AdwHeaderBar | pack_start / pack_end / set_title_widget, chosen by slot |
keyed | GtkStack | add_titled(child, name, title) |
coords | GtkGrid | attach(child, column, row, …) |
none | GtkLabel | rejected, with the three fixes named |
slot and layout are props the child declares: slot="end" picks a slotted attachment
point, layout={{ column, row }} a grid cell, layout={{ name, title }} a stack page.
Changing either re-places the child rather than doing nothing.
A container that cannot reorder in place says so instead of pretending. Adw.PreferencesGroup
has add and remove and no insert, so it is ordered with reorder: 'remove-all' and
pays a tail re-append. Its near-identical sibling Adw.PreferencesPage does have
insert(group, i), so it is indexed and places at the index directly. That is why the table
is measured per widget rather than inherited.
A dialog is presented, not adopted
Section titled “A dialog is presented, not adopted”One family of widgets takes none of the rules above, and the difference is not a degradation you
can work around. An Adw.Dialog is presented against a parent and never parented by it. Its
place in your tree and its place in the GTK tree are two different places, a portal. Put one in a
box and the process aborts.
So a dialog declares that instead, and the host places it by calling present() on the dialog
rather than an adder on the parent. Write it exactly where it belongs in your markup:
function Editor() { const [open, setOpen] = createSignal(false); return ( <gtk-box orientation="vertical" spacing={12}> <gtk-button label="Rename…" onClicked={() => setOpen(true)} /> <Show when={open()}> <adw-dialog title="Rename" onClosed={() => setOpen(false)}> <gtk-entry /> </adw-dialog> </Show> </gtk-box> );}Three things follow, and they are the same in all three adapters because the rule is the host’s:
- the dialog is not a child of the box. It takes no position in the box’s child list, so the button above it is the box’s only child and adding or removing the dialog never moves anything.
- rendering it opens it; unrendering it closes it. You make no imperative
presentcall and remember noclose. The dialog is up for exactly as long as the element is in your tree, and it closes unconditionally when the element goes, whatevercan-closesays. - it waits for a window. Renderers build a subtree before they insert it, so at the moment your dialog is created its parent is usually not in a window yet. Presenting then would open a stray toplevel; the host waits until the tree is really inside one, and presents there.
AdwDialog, AdwAlertDialog, AdwAboutDialog, AdwPreferencesDialog and AdwShortcutsDialog
are placed this way. The four subclasses build their own contents from their own API
(add_response, add) and inherit a set_child that would replace that. So each one is a tag
you can create, give properties and handlers, and present, while a child written inside one
is refused by name rather than silently wiping the widget’s own template. Put your own layout in
an <adw-dialog>; fill a subclass through its API, off a ref.
Long lists
Section titled “Long lists”A Gtk.ListView takes no children. It installs no append, no add and no set_child, so
<gtk-list-view> is a tag with no adoption rule and it throws when you put a child in it. GTK
recycles a small number of row widgets over a model instead, which is what keeps a list of
50 000 rows the same size on screen as a list of 50.
@gjsify/gtk-host/list drives that model. It has no framework in it, so one implementation
serves every adapter:
import { ListController, onScrollNearEnd } from '@gjsify/gtk-host/list';| Export | What it is |
|---|---|
new ListController(sink) | the model, the row factory and the key diff behind one Gtk.ListView |
ListRowKey | the one thing a row must carry: a key string |
ListRowSink | the three callbacks your framework supplies: mountRow, showRow, disposeRow |
onScrollNearEnd(scroller, axis, threshold, listener) | fires once each time the view arrives near the end; returns a disposer |
setRows(rows) compares keys. Same keys with changed content shows every live row again and
leaves the widgets in place. Changed keys splice the model, and GTK rebuilds the rows. Call
dispose() before the window goes away: it disconnects the factory’s handlers and releases
every row.
Solid has the row half already, as listRows:
import Gtk from 'gi://Gtk?version=4.0';
import type { HostNode } from '@gjsify/gtk-host';import { ListController } from '@gjsify/gtk-host/list';import { listRows, type ListRowHandle } from '@gjsify/gtk-host/solid';
type Row = { key: string; title: string };
const body = (row: () => Row, index: () => number): HostNode => (<gtk-label label={`${index()}: ${row().title}`} />) as HostNode;
const view = new Gtk.ListView();const list = new ListController<Row, ListRowHandle<Row>>(listRows<Row>(body));
list.attach(view);list.setRows([ { key: 'a', title: 'alpha' }, { key: 'b', title: 'beta' },]);A row body receives accessors rather than values, so a content change under an unchanged key writes one signal and updates one property. The row widget itself survives.
What each adapter has today:
| Adapter | Rows | How |
|---|---|---|
| Solid | yes | listRows from @gjsify/gtk-host/solid |
| React | through React Native | <FlatList> in @gjsify/react-native, which drives the same controller |
| Vue | not yet | write a ListRowSink against the controller |
Writing a sink is three functions and no GTK model code: mountRow(item) takes the carrier the
factory hands you, showRow(handle, row, index) puts a row into it or clears it with null,
and disposeRow(handle) lets go. Take the carrier with adopt() once, inside mountRow. GTK
reuses a carrier across binds, and adopting the same one twice raises occupied-slot.
Mount into a window you already own
Section titled “Mount into a window you already own”adopt(container) wraps a widget your application built as a host element and records the
children it already had, so the rendered tree lands after your own chrome rather than above
it. mount() in the Solid and Vue adapters and createRoot() in the React one all go through
it; call it yourself when you need the explicit spelling, such as a Vue <Teleport> target:
import Adw from 'gi://Adw?version=1';import Gtk from 'gi://Gtk?version=4.0';
import { adopt, mount } from '@gjsify/gtk-host/vue';
const myWindow = new Adw.ApplicationWindow({ title: 'Counter', default_width: 480 });const mySidebar = new Gtk.Box({ orientation: Gtk.Orientation.VERTICAL });
mount(App, myWindow); // adopts for youconst target = adopt(mySidebar); // then `:to="target"`A <Teleport> target must be a widget, never a string. Resolving a name would need a registry
of mounted roots; until something needs one, a string throws rather than rendering nothing.
The widget table and the type surfaces
Section titled “The widget table and the type surfaces”168 concrete GtkWidget descendants (105 from Gtk, 63 from Adw) are generated from the GIR
and committed with the package. 39 of them also carry a hand-written placement rule. A tag with
no rule can be created, given properties and given handlers; inserting a child into it raises an
error naming the tag that needs a policy. Nothing guesses, because add, append and
set_child all exist somewhere in GTK and calling the wrong one is a warning at exit 0.
The same generator emits the type surfaces, so a tag cannot mean one thing to the renderer and another to the type checker:
| Dialect | tsconfig | Tag spelling |
|---|---|---|
| Solid / JSX | "jsx": "preserve", "jsxImportSource": "@gjsify/gtk-host" | kebab (gtk-box) |
| React / JSX | "jsx": "react-jsx", "jsxImportSource": "@gjsify/gtk-host/react" | kebab (gtk-box) |
| Vue | import '@gjsify/gtk-host/vue-components' + "strictTemplates": true | either (GtkBox, gtk-box) |
The spellings are measured, not chosen. A capitalised JSX.IntrinsicElements key is never
consulted, because <GtkBox/> is TS2304: Cannot find name 'GtkBox'. Volar resolves a kebab
template tag to either key spelling but a Pascal tag only to a Pascal key, so one GType key
covers both. That key is also the table key and the GtkBuilder XML key.
noImplicitAny: true and strictTemplates: true matter for the same reason. Without them the
checker accepts everything and you conclude the types work. Each framework page names the exact
trap for its pipeline.
Read the source
Section titled “Read the source”Four showcases build the same window four ways, and each asserts the resulting tree against the real widget tree on every launch:
adw-host-counter: imperative, straight through the host opssolid-host-counter: the same window as Solid JSXvue-host-counter: the same window as a.vuesingle-file componentreact-host-counter: the same window as React, with no compile plugin at all
See also
Section titled “See also”- Solid JSX for the compile step a
.tsxentry needs - Vue SFCs for the compile step a
.vueentry needs - React for the
jsxImportSourceand build defines a.tsxentry needs instead of a compile step, and for the choice between its two element vocabularies - React Native on GTK for the second of those, if you are porting an app rather than starting one
- Native Adwaita Apps for the application shell you mount into
- GObject Classes for the
registerClassrules your own widget subclasses follow. The host resolves a subclass through its nearest registered ancestor - Adwaita gallery for the widgets themselves
- Effect if you already write TypeScript in that style. It is an optional, experimental integration for the non-rendering half, it is not part of the recommended path, and nothing above needs it