Skip to content

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
Terminal
npm install -D @gjsify/gtk-host
Terminal
yarn add -D @gjsify/gtk-host
Terminal
pnpm add -D @gjsify/gtk-host
Terminal
bun add --dev @gjsify/gtk-host
Terminal
deno add --dev npm:@gjsify/gtk-host
Terminal
gjsify install @gjsify/gtk-host

Your framework is a peer dependency and stays yours: solid-js, @vue/runtime-core, or react + react-reconciler. Install only the one you use.

AdapterImportFramework contractCompile step
Solid@gjsify/gtk-host/solidsolid-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/reactreact-reconciler HostConfignone — 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.

<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-native to. 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 as className="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.

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.

TypeScript
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 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.

src/App.vue
<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>
src/app.ts
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:

JSON
{
"jsx": "react-jsx",
"jsxImportSource": "@gjsify/gtk-host/react",
"noImplicitAny": true
}
TypeScript
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.

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 writeWhat GObject doesWhat the host does
orientation="vertical"keeps HORIZONTAL; the JS setter says nothing at allresolves the nick against the enum’s GType
orientation="sideways"the same silencethrows, naming GtkOrientation
a read-only propertyaccepts the write, stores nothingthrows, naming the property
a misspelled propertynothingthrows, naming the widget
text inside <gtk-image>nothingthrows, naming the tag and where the text could go
a child under a childless widgetGtk-WARNING at exit 0throws, naming the three fixes
selectable="false" as a stringJS truthiness makes it truehonours '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.

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:

KindExampleHow a child lands
singleAdwBin, GtkWindowset_child / set_content
orderedGtkBoxappend + insert_child_after
indexedGtkListBoxinsert(row, i), addressing a wrapper row
slottedAdwHeaderBarpack_start / pack_end / set_title_widget, chosen by slot
keyedGtkStackadd_titled(child, name, title)
coordsGtkGridattach(child, column, row, …)
noneGtkLabelrejected, 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 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:

TypeScript
import { ListController, onScrollNearEnd } from '@gjsify/gtk-host/list';
ExportWhat it is
new ListController(sink)the model, the row factory and the key diff behind one Gtk.ListView
ListRowKeythe one thing a row must carry: a key string
ListRowSinkthe 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:

TypeScript
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:

AdapterRowsHow
SolidyeslistRows from @gjsify/gtk-host/solid
Reactthrough React Native<FlatList> in @gjsify/react-native, which drives the same controller
Vuenot yetwrite 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.

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:

TypeScript
import { adopt, mount } from '@gjsify/gtk-host/vue';
mount(App, myWindow); // adopts for you
const 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.

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:

DialecttsconfigTag 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)
Vueimport '@gjsify/gtk-host/vue-components' + "strictTemplates": trueeither (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.

Four showcases build the same window four ways, and each asserts the resulting tree against the real widget tree on every launch:

  • Solid JSX for the compile step a .tsx entry needs
  • Vue SFCs for the compile step a .vue entry needs
  • React for the jsxImportSource and build defines a .tsx entry needs instead of a compile step
  • Native Adwaita Apps for the application shell you mount into
  • GObject Classes for the registerClass rules your own widget subclasses follow — the host resolves a subclass through its nearest registered ancestor
  • Adwaita gallery for the widgets themselves