# GPUIX **React** for [GPUI](https://github.com/zed-industries/zed/tree/main/crates/gpui), Zed's GPU UI framework. Write a React tree in TypeScript. GPUIX paints it with Metal, DirectX, or Vulkan. No Electron. No web view. `useState` and JSX still apply. Layout, text, and input go through GPUI, not the DOM. ![mail.tax example](./docs/images/mail-app.jpg) Everything above is GPUIX: the sidebar, the thread list, the reading pane, and native ``. ## Quickstart Create an app from the official example. The command downloads only `example-app/` and installs its dependencies. There is no repository clone, native build, or Rust toolchain. ```bash bunx @gpuix/cli new my-app cd my-app bun run dev ``` `@gpuix/react` pulls the native renderer for your platform. Edit `app.tsx` and the running window remounts on save. Click and keyboard handlers switch to the new tree without recreating the window. ### Build from scratch Install the packages directly when you do not want the example app: ```bash bun add --exact @gpuix/react @gpuix/native react bun add -d @types/react typescript ``` **Pin `@gpuix/react` and `@gpuix/native` to the same exact version.** GPUIX is still pre-1.0, so a new release can break either package. `@gpuix/react` pulls `@gpuix/native` with a version range, and that range can install a newer native binary under an older React package. Add both as direct dependencies. Upgrade them together. ### 1. Point TypeScript at the GPUIX JSX types **`jsxImportSource` is required.** Without it TypeScript uses DOM types, so ``, ``, `` and `style.hover` all fail to typecheck. ```json { "compilerOptions": { "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", "jsx": "react-jsx", "jsxImportSource": "@gpuix/react", "strict": true, "skipLibCheck": true, "noEmit": true } } ``` ### 2. Write the entry file End the file with `render()`. That call creates the window, mounts React, and starts the frame loop. ```tsx import { useState } from 'react' import { render } from '@gpuix/react' function App() { const [count, setCount] = useState(0) return (
setCount((c) => c + 1)} style={{ padding: 12, borderRadius: 8, cursor: 'pointer', backgroundColor: '#232323', hover: { backgroundColor: '#2c2c2c' }, }} > Count: {count}
) } render(, { title: 'My App', width: 800, height: 600 }) ``` > [!IMPORTANT] > **Give every `` a `color`.** GPUI does not inherit `color` from a > parent, so text with no color paints **black** and disappears on a dark > surface. ### 3. Run it ```bash bun --hot app.tsx ``` Use `bun --hot`, not plain `bun`. A save then remounts React on the same window instead of opening a second one. ### 4. Ship a binary ```bash bun build --compile app.tsx --outfile dist/app ./dist/app ``` The binary carries the renderer, so it runs with no Bun and no Node install. For a smaller ship set, run the same React app on [hermes-node](./website/src/guides/hermes.mdx) instead of Bun. That path is **12 MB** plus a **22 MB** native sidecar. The steps are in that guide. ### 5. Wrap it in an app with an icon A raw Mach-O has no Dock icon. Use [cargo-packager](https://github.com/crabnebula-dev/cargo-packager) to wrap the binary. Config: [Config](https://docs.rs/cargo-packager/latest/cargo_packager/config/struct.Config.html). CLI: [docs.rs/cargo-packager](https://docs.rs/cargo-packager/latest/cargo_packager/). ```bash cargo install cargo-packager --locked ``` Build an `.icns` from a 1024 PNG, then pack. Pass the `.icns`, not a 1024 PNG. cargo-packager rejected a 1024 PNG with `No matching IconType`. ```bash mkdir AppIcon.iconset sips -z 16 16 icon-1024.png --out AppIcon.iconset/icon_16x16.png sips -z 32 32 icon-1024.png --out AppIcon.iconset/icon_16x16@2x.png sips -z 32 32 icon-1024.png --out AppIcon.iconset/icon_32x32.png sips -z 64 64 icon-1024.png --out AppIcon.iconset/icon_32x32@2x.png sips -z 128 128 icon-1024.png --out AppIcon.iconset/icon_128x128.png sips -z 256 256 icon-1024.png --out AppIcon.iconset/icon_128x128@2x.png sips -z 256 256 icon-1024.png --out AppIcon.iconset/icon_256x256.png sips -z 512 512 icon-1024.png --out AppIcon.iconset/icon_256x256@2x.png sips -z 512 512 icon-1024.png --out AppIcon.iconset/icon_512x512.png sips -z 1024 1024 icon-1024.png --out AppIcon.iconset/icon_512x512@2x.png iconutil -c icns AppIcon.iconset -o AppIcon.icns ``` **Bun** (one binary): ```json { "productName": "My App", "version": "0.1.0", "identifier": "dev.example.app", "binariesDir": "dist", "outDir": "bundle", "binaries": [{ "path": "app", "main": true }], "icons": ["AppIcon.icns"], "formats": ["app"] } ``` ```bash cargo packager --release --config packager.json open "bundle/My App.app" ``` **Hermes** needs the `.node` next to the exe. List it as a **second binary**, not a resource. Resources go in `Contents/Resources`. `dlopen` looks in `Contents/MacOS`. ```json { "binaries": [ { "path": "gpuix-hermes", "main": true }, { "path": "gpuix-native.darwin-arm64.node", "main": false } ] } ``` `formats` is the host OS only: | OS | `formats` | Output | |---|---|---| | macOS | `"app"`, then `"dmg"` | `.app`, optional `.dmg` | | Windows | `"nsis"` | setup `.exe` | | Linux | `"appimage"` | `.AppImage` | On this machine the Bun chat `.app` is **82 MB**. The Hermes counter `.app` is **34 MB**. ### 6. Auto-update Packaging does **not** turn on updates. The running app calls `checkUpdate` on `@gpuix/native`. HTTP uses the same `reqwest_client` as ``. There is no second native addon. Host on **GitHub Releases**. Create the release first. CI packs on each OS, signs, and uploads the bundle plus its `.sig`. The app hits `https://github.com/OWNER/REPO/releases/latest`. That rewrites to `https://api.github.com/repos/OWNER/REPO/releases/latest`. The updater reads `tag_name` and `assets`, then GETs the sibling `{name}.sig`. Sign once: ```bash cargo packager signer generate ``` Store the private key and its password as repo secrets `CARGO_PACKAGER_SIGN_PRIVATE_KEY` and `CARGO_PACKAGER_SIGN_PRIVATE_KEY_PASSWORD`. Put the **public** key in the app. ```tsx import { checkUpdate } from '@gpuix/native' import { render } from '@gpuix/react' import { App } from './app' async function maybeUpdate() { const update = await checkUpdate('0.1.0', { endpoints: ['https://github.com/OWNER/REPO/releases/latest'], pubkey: '', }) if (update) await update.downloadAndInstall() } maybeUpdate() render() ``` `https://github.com/OWNER/REPO` is the same endpoint. Packager only builds the **host** OS. Run it on macOS, Linux, and Windows. `--release` is the packager profile (look in `binariesDir` for a release binary). It is not `cargo build --release`. Signing is automatic when those two env vars are set. Source: [cargo-packager CLI](https://docs.rs/cargo-packager/latest/cargo_packager/). With `productName: "My App"`, `version: "0.1.0"`, and `binaries: [{ "path": "app", "main": true }]`, packager writes: | OS | `formats` | Files in `outDir` (`bundle/`) | |---|---|---| | macOS | `"app"` | `My App.app`, then on sign `My App.app.tar.gz` + `My App.app.tar.gz.sig` | | Linux | `"appimage"` | `app_0.1.0_x86_64.AppImage` + `.sig` | | Windows | `"nsis"` | `app_0.1.0_x64-setup.exe` + `.sig` | The macOS updater wants the **`.app.tar.gz`**, not the `.app` and not a `.dmg`. Packager tars the `.app` only when it signs. Linux and Windows names use the **binary stem** (`app`), not `productName`. NSIS arch is `x64`, not `x86_64`. A missing sibling `.sig` is an error. Create the GitHub release yourself, then pack and upload. `--clobber` replaces an asset if CI retries. Do **not** upload a feed JSON. ```bash # macOS bun build --compile app.tsx --outfile dist/app cargo packager --release --config packager.json gh release upload v0.1.0 \ "bundle/My App.app.tar.gz" \ "bundle/My App.app.tar.gz.sig" \ --clobber # Linux bun build --compile app.tsx --outfile dist/app cargo packager --release --config packager.json gh release upload v0.1.0 \ bundle/app_0.1.0_x86_64.AppImage \ bundle/app_0.1.0_x86_64.AppImage.sig \ --clobber # Windows bun build --compile app.tsx --outfile dist/app.exe cargo packager --release --config packager.json gh release upload v0.1.0 \ bundle/app_0.1.0_x64-setup.exe \ bundle/app_0.1.0_x64-setup.exe.sig \ --clobber ``` `downloadAndInstall()` replaces the packaged files. It does **not** relaunch. Quit after it returns, or the next start uses the new app. HTTPS is in the native crate. This works on **Bun** and **hermes-node**. It does not exist in the browser wasm build. The repo must be **public**, or GitHub will 404 the API. Optional: put a Cloudflare cache in front of `api.github.com`. Same GitHub JSON. Not a custom schema. ### Start from the example app [`example-app/`](https://github.com/remorses/gpuix/tree/main/example-app) is a complete todo app in one file, with `dev`, `build`, `web:dev` and `typecheck` scripts already wired. Create a copy with `bunx @gpuix/cli new my-app`. ![The GPUIX todo example app](./docs/images/todo-app.png) ### Shell completions Install completions for the `gpuix` command: ```bash bun add -g @gpuix/cli gpuix completions install ``` ## Examples | Example | Run | What it shows | |---|---|---| | **todo** | `bun run dev` in [`example-app/`](https://github.com/remorses/gpuix/tree/main/example-app) | The starting point: one file, a ``, a native ``, and an animated sidebar | | **blurred window** | `bun run blurred-window` | A macOS frosted-glass surface using GPUI's native vibrancy backdrop and transparent titlebar | | **chat** | `bun --hot chat.tsx` | A GPUIX app: transparent titlebar, animated sidebar, per-thread transcripts, demo replies, composer, `` | | **timeline** | `bun --hot timeline.tsx` | A video-editor timeline: clip dragging, edge trimming with snapping, playhead scrubbing, marquee selection, zoom under the pointer, and a two-axis pan with a frozen ruler and track column | | **mail** | `bun --hot mail.tsx` | A Superhuman-style mail client: three panes, thread list, and a Framer newsletter | | **native-text** | `bun --hot native-text.tsx` | The three native text components with a tab switcher | | **counter** | `bun --hot counter.tsx` | The smallest possible app: state, events, hover | | **diff** | `bun --hot diff.tsx` | A diff viewer composed from `
` and `` in JS, for comparison | | **web** | `bun run web` from the repository root | The ChatGPT example rendered in a browser canvas with WebGPU | The todo app lives in [`example-app/`](https://github.com/remorses/gpuix/tree/main/example-app) and is meant to be copied with `bunx @gpuix/cli new`. The rest live in [`examples/`](https://github.com/remorses/gpuix/tree/main/examples). Those `bun --hot` commands need a clone of this repo and a local native build. They will not run against the published packages alone. Or download a standalone **chat** build from the [GitHub release](https://github.com/remorses/gpuix/releases). No Bun or Rust install is required. ```bash tar -xzf example-chat-aarch64-apple-darwin.tar.gz ./example-chat-aarch64-apple-darwin ``` The archive keeps the executable bit, so there is no `chmod` step. macOS may still block the unsigned binary the first time. Right-click the file, choose **Open**, and confirm. On Windows, download `example-chat-x86_64-pc-windows-msvc.exe` and double-click it. On Linux, the file is `example-chat-x86_64-unknown-linux-gnu.tar.gz`. The web example bundles the same React app and reconciler as the desktop chat example. wasm-bindgen exposes mutations and event callbacks to the existing retained tree and `GpuixView`, which run through GPUI's browser platform. The web build needs nightly Rust and the matching wasm-bindgen CLI: ```bash rustup toolchain install nightly --component rust-src --target wasm32-unknown-unknown cargo install wasm-bindgen-cli --version 0.2.127 --locked bun run web ``` The generated Wasm uses shared memory, so the page must be cross-origin isolated. Production servers must send these headers on the **top-level document**: ```http Cross-Origin-Opener-Policy: same-origin Cross-Origin-Embedder-Policy: require-corp ``` `require-corp` then constrains **cross-origin** subresources, which must supply their own CORS or `Cross-Origin-Resource-Policy`. Serve the JavaScript and the Wasm from the same origin as the document and nothing else is needed. `bun run web` rebuilds the Wasm only when `packages/native/wasm` is missing. After a Rust change, force it: ```bash bun scripts/web.ts --rebuild ``` #### Hot reload in the browser `bun run web` serves the example through Bun's frontend dev server, so an edit to `examples/chat.tsx` arrives as a **React Fast Refresh** update. Components swap in place and `useState` survives, which means the composer text, the sidebar selection, and the scroll position all stay where they were. The GPUI canvas is never re-created and the ~19 MB Wasm module is never re-fetched. Fast Refresh only applies to a module whose exports are all components. Edit anything else, such as the entry file, and Bun reloads the page instead. Both paths are correct; the reload is only slower. The Wasm half is a **singleton and must never re-evaluate**. `WebGpuixRenderer::init` fails with `GPUIX web is already running` once its thread-local app exists, and GPUI's browser platform appends its own canvas to ``. What protects it is not that it lives in `node_modules`; Bun bundles it into the same client registry as your app. It is that Bun re-runs only the **changed** module and then walks upward through its importers, so an unchanged dependency stays evaluated and cached. Two rules follow: - do not call `import.meta.hot.accept("./your-app", ...)` in the entry file. Bun runs an importer's dependency-accept callback **even when the imported module already self-accepted**, so that callback would remount the tree on top of a successful refresh and throw away every `useState` - keep the `@gpuix/native` import in a module that can never become a Refresh boundary and is never explicitly accepted The chat example puts a virtualized `` and a GFM table inside an assistant turn, inside a scrolling transcript: ![A diff and a markdown table inside a chat turn](./docs/images/chat-diff.png) Markdown, code and a virtualized diff in one frame: ![Markdown, code and diff rendered together](./docs/images/showcase.png) ## Architecture GPUIX bridges React to GPUI using a **mutation-based protocol**. Desktop apps use napi-rs; browser apps load the same Rust renderer through wasm-bindgen. React collects changed elements into one atomic mutation batch per commit. Rust applies that batch to a retained element tree that GPUI reads each frame. ``` ┌─────────────────────────────────────────────────────────────────┐ │ React (JavaScript) │ │ │ │ function App() { │ │ const [count, setCount] = useState(0) │ │ return ( │ │
│ │
setCount(c => c + 1)}> │ │ Count: {count} │ │
│ │
│ │ ) │ │ } │ └─────────────────────────────────────────────────────────────────┘ │ napi desktop / wasm-bindgen browser │ applyBatch([ │ ["createElement", 1, "div"], │ ["setStyle", 1, {...}], │ ["setRoot", 1] │ ]) ▼ ┌─────────────────────────────────────────────────────────────────┐ │ Rust host bridge │ │ │ │ RetainedTree ── stores elements, styles, event flags │ │ │ │ │ ▼ each GPUI frame │ │ GpuixView::render() → build_element() → GPUI elements │ └─────────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ GPUI │ │ │ │ Metal, DirectX, Vulkan, or browser WebGPU / WebGL2 │ │ 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 queues host mutations (`createElement`, `setStyle`, `appendChild`, etc.) 2. `applyBatch()` validates and applies the complete commit to the Rust **RetainedTree** 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 mutation surface between JS and Rust is one atomic method. Desktop uses napi and the browser uses wasm-bindgen: ```ts interface NativeRenderer { applyBatch(json: string): Array } ``` Element IDs are plain numbers generated by an incrementing counter in JS. React may abandon work in concurrent render mode, so GPUIX keeps new host nodes in JS until React places the accepted subtree during commit. Only then are its mutations added to the batch. `applyBatch()` applies that accepted commit atomically and marks the Rust view dirty for the next frame. ## Event Flow Events travel from GPUI back to React through a `ThreadsafeFunction` on desktop and a wasm-bindgen callback in the browser. ``` User clicks element id=3 │ ▼ GPUI fires on_click on the element │ ▼ Rust closure calls emit_event_full(callback, 3, "click", {x, y, ...}) │ ▼ Desktop ThreadsafeFunction / browser callback sends EventPayload │ ▼ 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 bindings to GPUI. It publishes napi-rs desktop binaries and a wasm-bindgen browser build, both backed by `GpuixRenderer`, `RetainedTree`, `build_element()`, and `apply_styles()`. - **`@gpuix/react`** — React reconciler, event registry, and TypeScript types. Implements the `react-reconciler` host config using the mutation API. - **`@gpuix/cli`** — `gpuix new` downloads `example-app/`, sets its published React dependency, and installs it as a standalone project. Pin `@gpuix/react` and `@gpuix/native` to the **same exact version**. GPUIX is still pre-1.0. Breaking changes can land before v1. Upgrade both together. ## Building This section is for **working on GPUIX itself**. To build an app with it, see [Quickstart](#quickstart) instead. Installing the packages needs no Rust toolchain and no submodule. ### Prerequisites 1. Rust toolchain 2. Node.js 18+ 3. Xcode with Metal Toolchain (macOS) ```bash # Install Metal Toolchain if needed xcodebuild -downloadComponent MetalToolchain # Install dependencies bun install # Check out the pinned GPUI fork git submodule update --init --recursive # 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 bun --hot counter.tsx ``` ## Usage ```tsx import React, { useState } from 'react' import { render } from '@gpuix/react' function App() { const [count, setCount] = useState(0) return (
setCount(c => c + 1)} >
Count: {count}
) } render(, { title: 'My App', width: 800, height: 600, titlebarTransparent: true, windowBackground: 'blurred', trafficLightX: 16, trafficLightY: 17, }) ``` `render()` creates the native window, mounts React, and starts the frame loop. The red traffic-light button quits the process. Start the app again from the terminal. | Option | Values | Purpose | |---|---|---| | `titlebarTransparent` | boolean | Hide the native titlebar so the app draws chrome under the traffic lights | | `windowBackground` | `"opaque"` (default), `"transparent"`, `"blurred"` | Window fill. `"blurred"` is the macOS vibrancy backdrop | | `trafficLightX` / `trafficLightY` | pixels | Traffic-light origin. The chat example uses `(16, 17)` | | `transparent` | boolean | Same as `windowBackground: "transparent"` when that option is unset | | `appName` | string | Name inside the macOS `Hide X` and `Quit X` items. Defaults to `title` | | `focus` | boolean, default `true` | `false` opens the window behind the active app, like `open -g` | | `show` | boolean, default `true` | `false` opens the window hidden. Call `activateWindow()` to reveal it | Call it again after a save and it remounts the tree on the same window. ### The macOS menu bar GPUIX installs the application menu bar for you, so a fresh app already answers `⌘Q`, `⌘H`, `⌥⌘H`, `⌘M`, and `⌘W`. Without it `NSApp.mainMenu` is nil, macOS paints an empty menu bar, and those shortcuts do not exist at all: AppKit only provides them through menu items. ``` Apple Window ├ Services ├ (AppKit window tiling) ├ Hide ⌘H ├ Minimize ⌘M ├ Hide Others ⌥⌘H ├ Zoom ├ Show All ├ Close Window ⌘W └ Quit ⌘Q └ (open windows) ``` **`appName` does not set the title of the application menu.** macOS takes that from the executable, so `bun app.tsx` shows `bun` during development and a `bun build --compile` binary shows its own file name. Only a real `.app` bundle changes it. `appName` reaches the items inside the menu, and nothing else. There is **no Edit menu**, on purpose. A menu key equivalent is consumed by AppKit before the window sees the key event, so an Edit menu carrying `⌘C` would take the keystroke away from text selection and from ``. Use **`render()`**, not `createRenderer()`, in the app entry. `bun --hot` re-runs the whole file on save. `createRenderer()` plus `init()` would then build a second host. `render()` is idempotent: the first call owns the window, later calls only remount React. `createRenderer()`, `createRoot()`, and `startFrameLoop()` stay public for tests and custom hosts. Pass `{ renderer }` into `render()` when you already have one. **One renderer drives one root.** A renderer owns one window, one native root id, and one event map, so `createRoot()` throws if that renderer already has a mounted root. Call `unmount()` on the first root before you create another; `render()` already does that for you. ### Background launch `focus: false` opens the window **without taking focus**. The app you were typing in keeps the caret and the active titlebar. `show: false` goes further and opens no window at all, so the process runs with a live React tree and nothing on screen. ```tsx render(, { title: 'Notes', focus: false }) ``` **Turn this on whenever a coding agent runs your app.** An agent that starts the app to check its work will otherwise yank the window in front of whatever you are doing, mid-sentence, once per iteration. With `focus: false` the agent still gets a real GPU-rendered window it can screenshot and click, and you keep your editor. See [Let an agent drive the app](#let-an-agent-drive-the-app). `activateWindow()` brings the window forward and focuses it. It is the only way to reveal a `show: false` window. Reach it from any component with `useGpuixRequired()`: ```tsx import { useGpuixRequired } from '@gpuix/react' function Reveal() { const renderer = useGpuixRequired() return
renderer.activateWindow?.()}>Show
} ``` Outside React, call it on the renderer that `createRenderer()` returned. | Platform | `focus: false` | `show: false` | |---|---|---| | macOS | window orders in front without becoming key, like `open -g` | honored | | Windows | `SW_SHOWNOACTIVATE` | honored | | Linux | **ignored**, the window opens focused | **ignored** | The process still gets a **Dock icon** on macOS. GPUI sets the regular activation policy, so there is no menu-bar-agent mode yet. For a real background daemon, run the app from a `launchd` agent in `~/Library/LaunchAgents/`; launchd never activates the process. ### Let an agent drive the app Make focus opt-in through the environment, so a human run behaves normally and an agent run stays out of the way: ```tsx render(, { title: 'Notes', focus: process.env.GPUIX_BACKGROUND !== '1', }) ``` ```bash bun app.tsx # you: window comes to the front GPUIX_BACKGROUND=1 bun app.tsx # agent: window opens behind your editor ``` `launch()` passes `env` straight through, so an agent script sets it once and every screenshot, click, and assertion runs on a window that never interrupts you: ```ts import { launch } from '@gpuix/react/automation' const app = await launch({ command: 'bun', args: ['app.tsx'], env: { GPUIX_BACKGROUND: '1' }, }) await app.getByTestId('bump').waitFor() await app.getByTestId('bump').click() await app.screenshot({ path: 'tmp/after-click.png' }) await app.close() ``` Focus is the only thing that changes. **Automation does not need focus.** `click()` hits the last painted bounds and `screenshot()` reads the GPU surface, so both work while the window sits behind your editor, and even on a `show: false` window that is not on screen at all. ``` agent ──► launch({ env: { GPUIX_BACKGROUND: '1' } }) │ ▼ GPU window renders and paints without activation │ ├──► getByTestId(..).click() ✓ hits the last painted bounds ├──► screenshot({ path }) ✓ reads the GPU surface ├──► fill() / press() ✓ uses the live input pipeline └──► close() you ──► keep typing, your editor stays frontmost the whole time ``` `fill()` and `press()` use the live GPUI window input pipeline. They work without activating the desktop window. **Linux ignores `focus`**, so an agent there still gets a focused window. Prefer `createTestRoot()` when you can. It opens **no window at all**, so nothing can steal focus and keyboard input works. Reach for `launch()` plus `focus: false` when the check needs a real window, real GPU paint, or a real process. ### flushSync The root is a **concurrent root**, so React commits in a later microtask. `flushSync` forces the render and the commit to finish before it returns, the same as in `react-dom`. ```tsx import { flushSync } from '@gpuix/react' flushSync(() => setSidebarOpen(true)) ``` It flushes **React only**, down to one `applyBatch` call. After it returns the native retained tree is up to date, including styles and text. It does **not** wait for GPUI. Layout and paint still happen on the next frame, exactly like the browser paints after a DOM mutation. To see pixels, wait a frame in the app, or call `renderer.flush()` in a test. Use it when an ordering bug depends on the commit landing first: an unmount before a remount, or a state change before you feed the next event. ## Debug frame overlay GPUI paints frame-time stats into the window after layout. The overlay is not a React element. A React FPS label would update every frame and cause more work. ```tsx render(, { title: 'My App', debugFrameOverlay: 'full' }) ``` | Mode | What you see | |---|---| | `hidden` | nothing (default) | | `minimal` | last draw time, e.g. `8.3 MS` | | `full` | `CUR`, `1%`, `10%`, `MAX`, `FRAMES` | Or call the renderer: ```ts renderer.setDebugFrameOverlay('full') renderer.cycleDebugFrameOverlay() renderer.resetDebugFrameOverlayStats() renderer.getDebugFrameOverlay() // 'hidden' | 'minimal' | 'full' renderer.getDebugFrameOverlayStats() // { currentMs, p90Ms, p99Ms, maxMs, frames, samples } ``` `p90Ms` is the overlay **10%** line. `p99Ms` is the **1%** line. Those are the slow tail. The overlay shows **draw time**, not FPS. `8.3 MS` is about 120 Hz. The chat example has a regression test for this: `examples/chat.perf.test.tsx`. It times mount, wheel draw, and sidebar clicks. It asserts p95, not every frame. The default example suite excludes this hardware-timing test so shared CI runner variance does not fail functional checks. Run it explicitly on the target Mac: On macOS, `THROTTLE=utility` restarts the process under `taskpolicy -c utility`. That pins work to E-cores. It is an **M1/M2 Air CPU** proxy, not Chrome 6x. GPU and RAM stay fast. `THROTTLE=background` is slower. ```bash cd examples THROTTLE=utility bun run test:perf THROTTLE=utility bun --hot chat.tsx ``` ## Hot reload ### 1. End the file with `render()` ```tsx import { render } from '@gpuix/react' function App() { return
hello
} render(, { title: 'My App', width: 800, height: 600 }) ``` Do **not** call `createRenderer()` or `init()` in this file. `bun --hot` re-runs the whole entry on save. A second `init()` would open a second window. ### 2. Start the app with `bun --hot` Prefer **`bun --hot`** over a plain `bun` or `tsx` run. Without `--hot`, a save starts a second process. With it, `render()` remounts React on the same window. ```bash bun --hot app.tsx ``` ### 3. Save the file ``` save .tsx ► bun re-evaluates the entry ► render() remounts React │ ▼ GpuixRenderer, window, GPU stay ``` The first `render()` creates the native host and stores it on `globalThis`. Each save unmounts the React tree and mounts a new one on that same host. **Stays:** window, GPU device, native `.node` addon, GPUI scroll physics. **Resets:** `useState`, focus, React event handlers. This is a remount, not React Refresh. Keeping hook state needs Bun to inject `$RefreshReg$` during `--hot`. That transform exists on `bun build --react-fast-refresh` only. Tracked in [oven-sh/bun#40179](https://github.com/oven-sh/bun/issues/40179). Native `.node` edits still need a rebuild. See [Developing the Rust side](#developing-the-rust-side). On **macOS**, `startFrameLoop` calls `renderer.tick()` at a fixed rate (~125fps by default). Each tick drains only ready AppKit events and Core Foundation sources, then returns without waiting for the next native wake. Bun timers, sockets, promises, and PTY callbacks can run between ticks. Pass `{ frameMs }` to change the rate, and call `.stop()` on the returned handle to end it. A **runtime throw does not freeze the window.** The frame loop catches errors from `tick()`, native event callbacks catch throws from React handlers, and `render()` installs `uncaughtException` / `unhandledRejection` listeners so bun stays alive. The window shows the stack and a **Reload** button that remounts the last `render()` tree. Save under `bun --hot` also remounts. The process does not exit. On **Windows and Linux**, GPUI runs its normal blocking native event loop on one dedicated Rust UI thread. `tick()` does not pump that loop. It only reports whether the UI thread is still inside `Platform::run`. `startFrameLoop` still creates a JavaScript timer so last-window-close can return false and `render()` can `process.exit`, matching macOS. All platforms use GPUI's native platform, window, renderer, input, scroll, clipboard, keyboard, and IME implementations. The embedded macOS run-loop extension comes from the pinned GPUIX fork. CI runs the full React and example test suites through DirectX on Windows. > [!IMPORTANT] > On macOS, never drive `tick()` from a `setImmediate` loop. That spins at tens of thousands of > ticks per second and burns **73% CPU on a completely idle app**, versus **1%** when > paced. ## Native animations Use **`motion.div`** to animate from an initial style to a target style. React sends the target once. Rust calculates intermediate values and requests GPUI frames until the transition finishes, without a React render or N-API call for each frame. ### Animate a target ```tsx import { motion } from '@gpuix/react' function WelcomeCard() { return ( Welcome ) } ``` Set **`initial={false}`** when the element must mount at its first `animate` target. Later `animate` changes still transition normally. If a target changes while motion is active, the next transition starts from the current visible value, so reversing an animation does not jump. ### Targets and timing Motion currently accepts these **numeric targets**: | Target | Range or unit | |---|---| | `width`, `height` | pixels, zero or greater | | `top`, `right`, `bottom`, `left` | pixels | | `opacity` | `0` through `1` | | `borderRadius` | pixels, zero or greater | The **transition** uses seconds, like Motion for React: | Option | Default | Values | |---|---:|---| | `duration` | `0.3` | Non-negative seconds | | `delay` | `0` | Non-negative seconds | | `ease` | `"easeOut"` | `"linear"`, `"ease"`, `"easeIn"`, `"easeOut"`, `"easeInOut"`, or `[x1, y1, x2, y2]` | Springs, keyframes, variants, exit transitions, and shared layout animations are not available yet. ### Animate a sidebar Animate an **outer clipping container** and keep the inner sidebar at a fixed width. This reveals or hides the content without reflowing its text on every frame. ```tsx import { motion } from '@gpuix/react' import type { ReactNode } from 'react' function SidebarFrame({ collapsed, children, }: { collapsed: boolean children: ReactNode }) { const sidebarWidth = 252 const dividerWidth = 1 return (
{children}
) } ``` The **chat example** uses this pattern. The sidebar remains mounted while its outer width moves between `253` and `0` pixels. ### Capture exact frames The [automation API](#automation) can freeze the native motion clock and render specific timestamps. This avoids timer sleeps and gives CI the same frames on every run. ```tsx import { connectTest } from '@gpuix/react/automation' import { createTestRoot } from '@gpuix/react/testing' import { ChatApp } from './chat' const { render, renderer } = createTestRoot() render() const app = await connectTest(renderer) const startedAt = await app.clock.pause() await app.getByTestId('sidebar-collapse').click() await app.captureFrames('review/sidebar', [ startedAt, startedAt + 50, startedAt + 100, startedAt + 150, startedAt + 200, ]) await app.clock.resume() ``` ## Scrolling Containers with `overflow: "scroll"` become natively scrollable. GPUI handles scroll physics, clipping, and offset persistence automatically. Plain scroll containers still build every child. Use `` below when the collection can grow large. > [!IMPORTANT] > **Nested scrolling is not supported.** One parent may scroll. An inner > `overflow: "scroll"`, ``, or `` must not. GPUI gives both > hitboxes the same wheel event, so the inner list steals the gesture. > > Keep long inner content in that parent. Collapse it behind an **expandable** > (preview plus Show more) instead of giving the child its own viewport. > > Horizontal overflow is the exception. `overflowX: "scroll"` on a wide child > (a code row, a table) does not steal the vertical wheel. GPUIX lays that > scroller out as a flex viewport with `minWidth: 0`. The wide child must not > shrink: set `flexShrink: 0` or a definite width. Swipe on **X** to pan. > A vertical wheel stays on the parent. ```tsx function Expandable({ preview, children, }: { preview: React.ReactNode children: React.ReactNode }) { const [open, setOpen] = useState(false) return (
{open ? children : preview} {!open &&
setOpen(true)}>Show more
}
) } ``` ```tsx function ScrollableList() { return (
{items.map((item, i) => (
{item.name}
))}
) } ``` Per-axis scrolling: use `overflowX: "scroll"` or `overflowY: "scroll"`. `overflow: "scroll"` scrolls both axes at once from a single diagonal gesture, like a browser. A flex column stretches its children to the cross axis, so a two-axis container needs its rows to state a width. Without one there is nothing to pan on **X**: ```tsx
{rows.map((row) => (
{row.cells}
))}
``` ### Panes that must move together A native scroll container cannot drive a **frozen header**. GPUI moves the container on the wheel frame, and the JavaScript callback that would move the header arrives a frame later, so the header tears away during a fast pan. When two panes must stay locked to the pixel, own the offset in React: put one `onScroll` listener on a non-scrolling parent, keep `scrollX` and `scrollY` in state, and translate each pane's content with an absolutely positioned wrapper. Zed does the same; the editor owns its scroll position and paints the gutter and the text from it. ```tsx function Pane({ offsetX, children }: { offsetX: number; children: React.ReactNode }) { return (
{/* An empty positioned box still takes hits, so opt it out. */}
{children}
) } ``` Keep the moving subtree in a `memo` component whose props do not change during a pan. The wheel then costs a handful of style mutations, not one per row. The [timeline example](./examples/timeline.tsx) does this for a ruler, a track column, and a clip grid. For programmatic scroll control, use a React ref to get the element's numeric ID, then call the renderer's scroll methods: ```tsx function ProgrammaticScroll() { const listRef = useRef(null) const jumpToBottom = () => { if (listRef.current) { renderer.scrollTo(listRef.current.id, 0, -999) } } return ( <>
{items.map((item, i) =>
{item}
)}
Jump to bottom
) } // 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 ``` ## Virtual lists Use `` for **long, variable-height collections** such as message lists. React and Rust retain every row, but GPUI only builds, lays out, and paints rows near the viewport. ```tsx function MessageList({ messages }: { messages: Message[] }) { return ( {messages.map((message) => ( ))} ) } ``` The list needs a **bounded height** or bounded flex space. Its direct children are rows and can contain any GPUIX host or custom element. | Prop | Default | Purpose | |---|---:|---| | `alignment` | `"top"` | Use `"bottom"` for chat-style initial positioning | | `followTail` | `false` | Follow appended rows until the user scrolls away | | `overdraw` | `512` | Extra pixels built outside the viewport | | `estimatedItemHeight` | none | Height hint for unmeasured rows. **Required** with `itemCount` | ### How virtualization works **React reconciliation stays normal.** The complete keyed child list crosses the mutation protocol and remains in Rust's retained tree. GPUIX defers only the expensive GPUI element construction, layout, and paint work. ```text React Fiber + Rust RetainedTree all row IDs, props, text, and events │ ▼ GPUI ListState row count and measured height cache │ ▼ visible indexes plus overdraw cx.processor re-enters GpuixView after root render │ ▼ fresh BuildCtx builds only the requested React subtree │ ▼ GPUI layout and paint visible rows only ``` ### Row heights **Rows do not need equal heights, and you do not need to know them.** GPUI measures a row when it enters the viewport. `estimatedItemHeight` is a **hint for rows nothing has measured yet**, not a size contract. ```text index: 0 1 2 3 4 5 6 7 ┌────────┬────────┬────────┬────────┬────────┬────────┬────────┬────────┐ │ hint │ hint │measured│measured│measured│ hint │ hint │ hint │ │ 220px │ 220px │ 184px │ 512px │ 96px │ 220px │ 220px │ 220px │ └────────┴────────┴────────┴────────┴────────┴────────┴────────┴────────┘ ▲ ▲ ▲ │ │ │ estimate only real, variable heights estimate only (viewport plus overdraw) ``` The sum of that height cache is the scroll length, so a rough estimate only affects **scrollbar accuracy** before a row is visited. The measured height replaces the estimate automatically, and the scrollbar converges as you scroll. When a retained descendant changes, GPUIX marks its direct row for remeasurement, so a streaming row grows correctly. Appending, removing, or reordering keyed rows keeps measurements for rows whose IDs did not change. `estimatedItemHeight` is optional in children mode, where every row exists and can be measured. It is **required** with `itemCount`, because React never mounts the rows outside the window and native has no element to measure. Those indexes render as an empty box of the estimated height until React mounts the real row. ### Row boundaries Each **direct host child** is one virtual row. Give every row a stable React key and one host root: ```tsx {messages.map((message) => (
))}
``` A row can contain nested `
`, ``, ``, ``, ``, ``, and `