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:

  1. React reconciler detects a state change and calls napi mutations (createElement, setStyle, appendChild, etc.)
  2. Each mutation updates a RetainedTree on the Rust side — a HashMap of element nodes with styles, children, and event flags
  3. On each GPUI frame, GpuixView::render() walks the RetainedTree and calls build_element() to produce ephemeral GPUI elements
  4. GPUI lays them out (Taffy flexbox) and renders to the GPU
  5. 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. Contains GpuixRenderer, RetainedTree, build_element(), apply_styles(), and the event wiring.
  • @gpuix/react — React reconciler, event registry, and TypeScript types. Implements the react-reconciler host config using the mutation API.

Building

Prerequisites

  1. Rust toolchain
  2. Node.js 18+
  3. 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

ElementDescription
divContainer with flexbox layout
textText content
imgImages (planned)
svgVector graphics (planned)
canvasCustom drawing (planned)

Supported Events

EventPropsPayload fields
ClickonClickx, y, clickCount, isRightClick, modifiers
Mouse downonMouseDownx, y, button, clickCount, modifiers
Mouse uponMouseUpx, y, button, clickCount, modifiers
Mouse enteronMouseEnterhovered
Mouse leaveonMouseLeavehovered
Mouse moveonMouseMovex, y, pressedButton, modifiers
Click outsideonMouseDownOutsidex, y, button, modifiers
Key downonKeyDownkey, keyChar, isHeld, modifiers
Key uponKeyUpkey, keyChar, modifiers
FocusonFocus
BluronBlur
ScrollonScrolldeltaX, 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: pre is not supported. GPUI's text system only has normal (wraps) and nowrap (single line). To preserve newlines like HTML <pre>, split your text on \n in 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 color from parent elements. Every <text> element that doesn't set an explicit color style will render as black — invisible on dark backgrounds. Always set color on your text elements or on a parent <div> (which applies text_color to all children in that subtree via GPUI's Styled trait).

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