How it works

One readable language, from button to kernel.

We took one card from omxplorer and followed it all the way down: from a line of Mojo to a GTK widget, an HTML element and a Mac view. Same file. Same numbers. Three native toolkits.

One card, all the way down
omxplorer's Report over a million orders: KPI tiles above two charts
This is omxplorer's Report, over a million orders. Look at the first tile, the sum of revenue.
THE APP · examples/omxplorer/app.mojo
KpiTile(card)
# one line puts a tile on the Report
kpis.append(KpiTile(kpi.view(…)))

The app says what it wants: a KPI tile holding this card. No colours, no pixels, no platform.

THE FRAMEWORK · mojoui/reportpage.mojo
.tag("tile")
def KpiTile(var card: View) -> View:
    return card^.fill().tag("tile")

The component gives the card a role: tile. A role is a name every theme and every host agrees on.

In the app it's one call. In the framework it's a card with a role, "tile".
THE THEME · themes/liquid.mojo
solid("tile", 16, 16, 18, 16)
# mojoui/theme.mojo
def solid(role, radius, pad_top, pad_x, pad_bottom) -> Surface:
    """An opaque white card with a hairline ring
       and a faint shadow (the content: a table, a tile)."""
corners16
padding16 · 18 · 16
fillwhite
ring1 pt, slate at 7 %
shadow1 down, 3 soft, 5 %
The theme says what a tile looks like, as numbers. Not CSS: a Mojo value any host can read.
Linux GTK 4 · C

Mojo calls GTK's C functions directly, then writes the theme's numbers as GTK CSS.

w = external_call["gtk_box_new"](VERTICAL, 0)
add_class(w, "mui-tag-tile")

.mui-tag-tile { background: rgb(255,255,255);
 border-radius: 16px; padding: 16px 18px;
 box-shadow: 0 0 0 1px rgba(15,23,42,0.07),
   0 1px 3px rgba(15,23,42,0.05); }
Web HTML · CSS

The same compiled app runs in a Python web server; the browser gets HTML, then only what changed.

<div class="vstack card"
     data-tag="tile">

[data-tag=tile] { background: rgb(255,255,255);
 border-radius: 16px; padding: 16px 18px;
 box-shadow: 0 0 0 1px rgba(15,23,42,0.07),
   0 1px 3px rgba(15,23,42,0.05); }
Mac AppKit · Core Animation

No Swift: Mojo sends Objective-C messages itself, and paints the layer from the same numbers.

msg(layer, "setBackgroundColor:", white)
msg(layer, "setCornerRadius:", 16)
msg(layer, "setBorderWidth:", 1)
msg(layer, "setShadowRadius:", 1.5)
msg(w, "setEdgeInsets:", 16, 18, 16, 18)
Each host translates the same numbers into its own toolkit's words. The CSS on the left is what the framework wrote, verbatim; on the Mac the values are shown resolved.
The tile drawn by GTK 4 on Linux
LinuxGTK 4 · 347 pt wide
The tile drawn by the browser
WebChromium · 347 pt wide
The tile drawn by AppKit on macOS
MacAppKit, macOS 26 · 347 pt wide
Three toolkits. One card. Real pictures, not mock-ups: the same app, built from the same commit.

This is omxplorer's Report, over a million orders. Watch the first tile.

One KPI tile. Let's follow it down, from the app to the screen.

In the app it's one call. In the framework it's a card with a role: "tile".

The theme says what a tile looks like, as numbers. Not CSS: values any host can read.

Each host says the same numbers in its own toolkit's words. GTK and the web get CSS written for them; the Mac gets layer calls.

Three toolkits. One card. Real pictures from the same commit: Linux, the web, the Mac.

We don't draw a single pixel. We hand out specs.

Mojo says what's on the screen and what it looks like, as names and numbers. GTK, the browser and AppKit each draw it the way they draw best. And the same spec that tells AppKit how to draw a tile tells an agent what it can press. Agents love a good spec.

Agents: you're welcome.

The loop

A press goes in. A patch comes out.

Every omxcode app is two functions: update changes the state, view describes the whole screen. The framework works out what changed and tells each toolkit only that.

1

You press "Format"

a native button fires its signal
2

update(event)

the app changes its state: plain Mojo
3

view()

the whole screen, as a flat list of nodes
4

diff(before, after)

same shape? a few patches. new shape? a rebuild
5

Each host applies it

GTKWebAppKitAgent
WHAT HAPPENS · one press

An illustration of the path, step by step; the functions are the real ones (architecture.md).

Under the hood

Seven small ideas doing the heavy lifting.

01 · No bindings

Mojo calls C, and C calls Mojo.

external_call["gtk_button_new_with_label"](title)

No binding generator, no wrapper library. A Mojo function marked abi("C") is a plain function pointer GTK can call back.

mojoui/hosts/gtk.mojo
02 · No Swift

The Mac app is Mojo talking to Objective-C.

msg(button, "setTitle:", ns_string("Go"))

Every AppKit call is a message, so Mojo sends it itself, and even makes its own classes at run time. It's cross-compiled, linked and signed on Linux.

mojoui/hosts/objc.mojo · appkit.mojo
03 · Screens are data

The whole screen is one flat list.

VStack(Text("Hi"), Button("Go", on_tap=GO))
# → [VSTACK 1,2] [TEXT "Hi"] [BUTTON "Go" GO]

Flat lists compare in one pass and pair one-to-one with native widgets, so a diff is cheap and a host stays thin.

mojoui/view.mojo · diff.mojo
04 · Messages are numbers

Widgets never hold your code.

comptime GO = 1

A button carries a number, not a closure. When it fires, the host looks the number up in the current screen, so nothing ever needs rewiring.

docs/decisions.md · D-19
05 · A theme is values

One set of numbers, three toolkits.

solid("tile", 16, 16, 18, 16)

GTK and the web get CSS written from it; the Mac gets layer calls. A lint stops a theme from styling anything that isn't a framework role, so a new app looks right with no CSS of its own.

mojoui/theme.mojo · look.mojo
06 · The agent is a host

Every button is already a tool.

Save → file-save
Search… → search {text}

Next to GTK, the web and AppKit sits a fourth host that speaks MCP. A disabled button isn't a tool, so an agent can't do anything you couldn't.

docs/agent.md
07 · Measured, every push

The Mac is checked against the web, to the point, on GitHub's own Macs.

Each host writes down where every tagged view landed. A script compares the Mac's list with the browser's and fails if one is more than 4 pt (or 3 %) away. No Mac on the desk needed: the app is built on Linux, run on GitHub's macOS 15 and 26 runners, and its pictures and view lists come back for the check.

scripts/parity.py · scripts/mac-ci · ci/mac/side.txt
The stack

One core. A thin host per platform.

Only the bottom row knows a platform exists. Everything above it is plain Mojo, tested on its own.

app.mojostate · update(event) · view() · draw(canvas) · your GPU kernels
mojoui · the core
Componentsview.mojo · 36 of them
Diffdiff.mojo · patches or rebuild
Looktheme.mojo · look.mojo
Data & GPUomxcore · MAX
hosts · also Mojo
gtk.mojoC calls
runs
web.mojo + web.pya Python module
runs
appkit.mojoobjc_msgSend
runs in CI
agentMCP over stdio
runs
what they drive
GTK 4Linux
The browserHTML, patches
AppKitmacOS 15+
Claude, any MCP clienttools by name

Also in the repo: Android views through JNI (runs on the emulator) and UIKit for iPhone (builds, not run on hardware yet).

From button to kernel

The GPU code is in the same language.

A chart's query runs as a GPU kernel written in Mojo, compiled by Modular's MAX for whatever GPU the machine has. No CUDA file, no Metal file, no second toolchain.

A KERNEL · ci/mac/gpuprobe.mojo (run on GitHub's Mac)
def add_index(data: UnsafePointer[Float32, …], n: Int32):
    var i = Int(global_idx.x)
    if i < Int(n):
        data.unsafe_offset(i)[] = data.unsafe_offset(i)[] + Float32(i)

var ctx = DeviceContext()
print("DEVICE:", ctx.name(), "·", ctx.api())
ctx.enqueue_copy(dst_buf=buf, src_ptr=host.unsafe_ptr())
ctx.enqueue_function[add_index](buf.unsafe_ptr(), Int32(N),
                                grid_dim=N // 256, block_dim=256)
WHAT IT PRINTED · macOS 26 runner
DEVICE: Apple Paravirtual device · metal
RESULT: ok   # 4,096 values in, each plus its index, checked

The same Mojo, compiled for Apple's Metal by MAX (--target-accelerator metal:1), on a GitHub Mac. So the Mac's GPU path can be tested on every push.

runs today
0.7ms

NVIDIA, on Linux

A chart grouped over a million rows on an RTX 3090, in the same process that draws the window. Next: a stream per query, and recorded kernel chains for repeat clicks.

runs
metalthe app's queries

Apple, on the Mac

Works: the app's own queries run on a Mac's GPU through Metal: on GitHub's Macs, a million-row sheet's totals and the Report's tiles and chart ran on its GPU, the totals equal to the CPU's to the cent. The download is this build (macOS 26), checked on the runner with no Mojo installed. Next: timings on real Apple silicon (GitHub's GPU is a virtual one), and macOS 14 and 15.

on the server
webserver GPU

The browser

The web version runs the same compiled app on a server, with its GPU. In-browser WebGPU waits for a Mojo route to it: we won't add a second language to get there.

Proof

Don't take our word. Drag the line.

The same Report on two platforms, from the same commit at the same window size. Drag across to compare.

The Report on the web The Report on the Mac WebMac
1440 × 640 · Mac: GitHub's macOS 26 runner · Linux: GTK 4 · web: Chromium
21of 21toolbar views on the Mac within 4 pt of the web's
4ptthe tolerance every tagged view is held to (or 3 %)
2MacsmacOS 15 and 26 runners, on every push to the test branch

Still different, and being closed

  • Text renders a touch wider and heavier on the Mac.
  • The tile above is the same width everywhere; on the Mac it's 8 pt shorter.
  • The inspector's line spacing is a few points off; the bottom sheet tabs aren't styled yet.
  • A tile's ✕ shows on hover on the web, always on the Mac.

How it's checked

  • scripts/side.py shoots each screen on GTK and the web at 1440 × 640.
  • scripts/mac-ci builds the Mac app on Linux and runs the same screens on GitHub's Macs.
  • scripts/parity.py compares every tagged view's rectangle, Mac against web.
  • The pictures are compared too: the Mac may differ from the web by no more than 1.5 × what Linux does.
Download

Try it. Then read the code.

A public alpha, free and open source under MIT.

Mac Apple Silicon

omxplorer as a Mac app: open a CSV, edit it like a spreadsheet, build a report.

Download for Mac

Its queries run on the Mac's GPU, through Metal. It needs macOS 26; on macOS 14 or 15, the CPU build runs the same app. Checked on GitHub's macOS 26 runner, whose GPU is a virtual one: not yet timed on real Apple silicon. It carries four of Modular's runtime libraries, unmodified, with their licence (NOTICE.txt). It isn't notarised yet: if macOS refuses it, run xattr -dr com.apple.quarantine OmxplorerMojo.app, or open it once, then System Settings → Privacy & Security → Open Anyway.

Linux best today

The full app, with the GPU underneath on an NVIDIA card. Unpack and run ./omxplorer, or ./install.sh for a launcher entry.

Download for Linux

x86_64. It needs a recent distro with GTK 4, libadwaita and WebKitGTK 6.0 (Ubuntu 24.04 or Fedora 40 should do; so far it has run on one Arch-based machine, not yet on those); queries use the GPU when NVIDIA's driver is there, the CPU otherwise. It carries four of Modular's runtime libraries, unmodified, with their licence (NOTICE.txt). From the source instead: pixi install, then scripts/omxplorer.sh (or scripts/omxplorer.sh web for the web version).

The source MIT

Everything on this page is in the repository: the hosts, the look, the checks.

Download the source

Start with architecture.md. Building with an agent? Point it at llms.txt.