GPUIX
March 2, 2026 · View on GitHub
React bindings for GPUI - Zed's GPU-accelerated UI framework.
Build native GPU-accelerated desktop apps with React and TypeScript. Your components render directly to the GPU via Metal/Vulkan — no Electron, no web views.
Architecture
GPUIX bridges React to GPUI using a mutation-based protocol over napi-rs FFI. React's reconciler sends individual DOM-like mutations (createElement, appendChild, setStyle, etc.) directly to Rust — no JSON tree serialization. Rust maintains a retained element tree that GPUI reads each frame.
┌─────────────────────────────────────────────────────────────────┐
│ React (JavaScript) │
│ │
│ function App() { │
│ const [count, setCount] = useState(0) │
│ return ( │
│ <div style={{ display: 'flex', gap: 8 }}> │
│ <div onClick={() => setCount(c => c + 1)}> │
│ Count: {count} │
│ </div> │
│ </div> │
│ ) │
│ } │
└─────────────────────────────────────────────────────────────────┘
│ napi FFI mutations
│ createElement(1, "div")
│ appendChild(0, 1)
│ setStyle(1, "{...}")
│ commitMutations()
▼
┌─────────────────────────────────────────────────────────────────┐
│ Rust (napi-rs) │
│ │
│ RetainedTree ── stores elements, styles, event flags │
│ │ │
│ ▼ each GPUI frame │
│ GpuixView::render() → build_element() → GPUI elements │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ GPUI │
│ │
│ GPU-accelerated rendering via Metal (macOS) / Vulkan (Linux) │
│ Flexbox layout via Taffy │
└─────────────────────────────────────────────────────────────────┘
Why This Works
GPUI is an immediate-mode UI framework — it rebuilds the entire element tree every frame. Instead of fighting this, GPUIX embraces it:
- React reconciler detects a state change and calls napi mutations (
createElement,setStyle,appendChild, etc.) - Each mutation updates a RetainedTree on the Rust side — a HashMap of element nodes with styles, children, and event flags
- On each GPUI frame,
GpuixView::render()walks the RetainedTree and callsbuild_element()to produce ephemeral GPUI elements - GPUI lays them out (Taffy flexbox) and renders to the GPU
- Only changed elements cross the FFI boundary — React's reconciler diffs the virtual tree and sends minimal mutations
This is the same protocol React uses for the DOM (createElement, appendChild, removeChild, commitUpdate), but targeting a GPU renderer instead of a browser.
Mutation API
The FFI surface between JS and Rust is a set of direct napi calls — the NativeRenderer interface:
interface NativeRenderer {
createElement(id: number, elementType: string): void
destroyElement(id: number): Array<number>
appendChild(parentId: number, childId: number): void
removeChild(parentId: number, childId: number): void
insertBefore(parentId: number, childId: number, beforeId: number): void
setStyle(id: number, styleJson: string): void
setText(id: number, content: string): void
setEventListener(id: number, eventType: string, hasHandler: boolean): void
setRoot(id: number): void
commitMutations(): void
}
Element IDs are plain numbers generated by an incrementing counter in JS. commitMutations() signals the end of a batch — Rust marks the view dirty so GPUI re-renders on the next frame.
Event Flow
Events travel from GPUI back to React through a ThreadsafeFunction callback:
User clicks element id=3
│
▼
GPUI fires on_click on the element
│
▼
Rust closure calls emit_event_full(callback, 3, "click", {x, y, ...})
│
▼
ThreadsafeFunction queues EventPayload on Node.js event loop
│
▼
JS event registry: eventHandlers.get(3)?.get("click")?.(payload)
│
▼
React handler runs: onClick={() => setCount(c => c + 1)}
│
▼
State update triggers re-render → reconciler sends mutations back to Rust
Event handlers are stored in a JS-side registry keyed by (elementId, eventType). Rust only knows whether an element has a listener (via setEventListener), not the closure itself — the actual handler lives in JS.
Packages
@gpuix/native— Rust/napi-rs bindings to GPUI. ContainsGpuixRenderer,RetainedTree,build_element(),apply_styles(), and the event wiring.@gpuix/react— React reconciler, event registry, and TypeScript types. Implements thereact-reconcilerhost config using the mutation API.
Building
Prerequisites
- Rust toolchain
- Node.js 18+
- Xcode with Metal Toolchain (macOS)
# Install Metal Toolchain if needed
xcodebuild -downloadComponent MetalToolchain
# Install dependencies
bun install
# Build native package
cd packages/native
bun run build
# Build React package
cd ../react
bun run build
# Run example (use tmux for long-running sessions)
cd ../../examples
npx tsx counter.tsx
Usage
import React, { useState } from 'react'
import { createRoot, createRenderer, flushSync } from '@gpuix/react'
function App() {
const [count, setCount] = useState(0)
return (
<div style={{ display: 'flex', gap: 8, padding: 16 }}>
<div
style={{ backgroundColor: '#3b82f6', borderRadius: 8, padding: 12, cursor: 'pointer' }}
onClick={() => setCount(c => c + 1)}
>
<div style={{ color: '#ffffff' }}>Count: {count}</div>
</div>
</div>
)
}
// Create the native renderer with event callback
const renderer = createRenderer((event) => {
console.log('Event:', event.elementId, event.eventType)
})
// Initialize GPUI (non-blocking — returns immediately)
renderer.init({ title: 'My App', width: 800, height: 600 })
// Create React root and render
const root = createRoot(renderer)
flushSync(() => root.render(<App />))
// Drive the frame loop
function loop() {
renderer.tick()
setImmediate(loop)
}
loop()
Scrolling
Containers with overflow: "scroll" become natively scrollable — GPUI handles scroll physics, clipping, and offset persistence automatically.
function ScrollableList() {
return (
<div style={{ height: 300, overflow: 'scroll' }}>
{items.map((item, i) => (
<div key={i} style={{ height: 60, padding: 12 }}>
{item.name}
</div>
))}
</div>
)
}
Per-axis scrolling: use overflowX: "scroll" or overflowY: "scroll".
For programmatic scroll control, use a React ref to get the element's numeric ID, then call the renderer's scroll methods:
function ProgrammaticScroll() {
const listRef = useRef<any>(null)
const jumpToBottom = () => {
if (listRef.current) {
renderer.scrollTo(listRef.current.id, 0, -999)
}
}
return (
<>
<div ref={listRef} style={{ height: 200, overflow: 'scroll' }}>
{items.map((item, i) => <div key={i}>{item}</div>)}
</div>
<div onClick={jumpToBottom}>Jump to bottom</div>
</>
)
}
// Available scroll methods on the renderer:
renderer.scrollTo(elementId, x, y) // set offset directly
renderer.scrollToItem(elementId, index) // scroll child into view
renderer.getScrollOffset(elementId) // returns [x, y] or null
Supported Elements
| Element | Description |
|---|---|
div | Container with flexbox layout |
text | Text content |
img | Images (planned) |
svg | Vector graphics (planned) |
canvas | Custom drawing (planned) |
Supported Events
| Event | Props | Payload fields |
|---|---|---|
| Click | onClick | x, y, clickCount, isRightClick, modifiers |
| Mouse down | onMouseDown | x, y, button, clickCount, modifiers |
| Mouse up | onMouseUp | x, y, button, clickCount, modifiers |
| Mouse enter | onMouseEnter | hovered |
| Mouse leave | onMouseLeave | hovered |
| Mouse move | onMouseMove | x, y, pressedButton, modifiers |
| Click outside | onMouseDownOutside | x, y, button, modifiers |
| Key down | onKeyDown | key, keyChar, isHeld, modifiers |
| Key up | onKeyUp | key, keyChar, modifiers |
| Focus | onFocus | — |
| Blur | onBlur | — |
| Scroll | onScroll | deltaX, deltaY, precise, touchPhase, modifiers |
Keyboard and focus events require the element to be focusable (has onKeyDown, onKeyUp, onFocus, or onBlur listeners). GPUI creates a FocusHandle automatically for these elements.
Supported Styles
CSS-like styling via the style prop:
<div style={{
display: 'flex',
flexDirection: 'column',
gap: 8,
padding: 16,
backgroundColor: '#3b82f6',
borderRadius: 8,
}}>
<div style={{ color: '#ffffff', fontSize: 18 }}>
Hello GPUI!
</div>
</div>
Layout: display, flexDirection, flexGrow, flexShrink, alignItems, justifyContent, gap
Sizing: width, height, minWidth, minHeight, maxWidth, maxHeight — accepts pixels (number) or percentages (string like "100%")
Spacing: padding, paddingTop/Right/Bottom/Left, margin, marginTop/Right/Bottom/Left
Visual: backgroundColor, color, opacity, cursor, borderRadius, borderWidth, borderColor
Overflow: overflow, overflowX, overflowY — "hidden" clips content, "scroll" creates a native scrollable container with persistent scroll state
Text: fontSize, fontFamily, fontWeight, whiteSpace, textOverflow, lineClamp
Note:
white-space: preis not supported. GPUI's text system only hasnormal(wraps) andnowrap(single line). To preserve newlines like HTML<pre>, split your text on\nin React and render each line as a separate<text>element in a flex column:<div style={{ display: 'flex', flexDirection: 'column', fontFamily: 'Menlo' }}> {code.split('\n').map((line, i) => ( <text key={i} style={{ whiteSpace: 'nowrap' }}>{line}</text> ))} </div>
Note: GPUI defaults text color to black, not white. Unlike CSS, GPUI does not inherit
colorfrom parent elements. Every<text>element that doesn't set an explicitcolorstyle will render as black — invisible on dark backgrounds. Always setcoloron your text elements or on a parent<div>(which appliestext_colorto all children in that subtree via GPUI'sStyledtrait).
Testing
GPUIX includes a GPU-backed test renderer (TestGpuixRenderer) that runs the full GPUI rendering pipeline — same GpuixView, build_element(), apply_styles(), and event handlers as production. Windows are positioned offscreen but fully rendered by Metal.
import { createTestRoot } from '@gpuix/react/testing'
const { root, renderer } = createTestRoot()
root.render(<MyComponent />)
renderer.flush() // triggers GpuixView::render() via Metal
// Simulate events through GPUI's native input pipeline
renderer.nativeSimulateClick(50, 50)
renderer.nativeSimulateKeystrokes('enter')
// Inspect results
const events = renderer.drainNativeEvents()
const screenshot = renderer.captureScreenshot('/tmp/test.png')
const text = renderer.getAllText()
The test renderer uses VisualTestAppContext with a TestDispatcher for deterministic scheduling. Event simulation goes through GPUI's coordinate-based hit testing and dispatch — not synthetic JS events.
Status
- React reconciler with mutation-based protocol
- napi-rs FFI bindings (createElement, appendChild, setStyle, etc.)
- RetainedTree (Rust-side element storage)
- Style mapping (CSS properties → GPUI style methods)
- Mouse events (click, mouseDown, mouseUp, mouseMove, mouseEnter, mouseLeave)
- Click outside (
onMouseDownOutside) - Scroll wheel events with delta and touch phase
- Scrollable containers (
overflow: "scroll") with persistent scroll state - Programmatic scroll API (
scrollTo,scrollToItem,getScrollOffset) - Keyboard events (keyDown, keyUp) with focus management
- Focus/blur events with automatic FocusHandle creation
- GPU-backed test renderer with screenshot capture
- Standalone build (pinned GPUI + macOS deps)
- Text input (GPUI has no built-in input element)
- Image and SVG elements
- Multiple windows
- Hot reload
- Animations
Documentation
See AGENTS.md for detailed architecture, communication flow, and contributing guide.
License
Apache-2.0