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 exactly what a UI framework is for. Solid, Vue and React can all render somewhere other than a browser — they just 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 |
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.
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:
- React Native — React Native’s export surface on GTK 4, 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 a divergence is something you read rather than something you find in a window. - Styling on GTK —
@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 and not part of the React Native package. It also carries the traps that make GTK styling fail silently, each one measured.
Solid JSX
Section titled “Solid JSX”registerBuiltinWidgets() loads the generated table; after that the tags are available.
Build with @gjsify/rolldown-plugin-solid, which is what 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 will reach
for the last one less often here than in Solid or React: you already hold the window you
mounted into, and a widget inside the tree is what a ref on the element gives you, as in
any Vue app. widgetOf is there 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 { createRoot } from '@gjsify/gtk-host/react';
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"' --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.
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 { adopt, mount } from '@gjsify/gtk-host/vue';
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 — and that key is also the table key and the GtkBuilder XML key.
noImplicitAny: true and strictTemplates: true are load-bearing in the same way: without
them the checker accepts everything and you conclude the surface works. 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 - 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