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

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

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.

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

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

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.

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:

TypeScript
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 present call and remember no close. The dialog is up for exactly as long as the element is in your tree, and it closes unconditionally when the element goes, whatever can-close says.
  • 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.

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

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, 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 registerClass rules 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