Agent-readable docs index: /llms.txt. Full docs in one file: /llms-full.txt. Download /docs.zip to grep all markdown files locally.
GPUIX
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, DirectX, or Vulkan. No Electron, no web views.
Everything above is GPUIX: the sidebar, the scrolling list, the composer,
and native <markdown>. Start it with bun --hot so a save remounts React
on the same window:
1cd examples && bun --hot chat.tsx
Quickstart
Install two packages. @gpuix/react pulls the native renderer for your
platform, so there is nothing to build and no Rust toolchain to install.
12bun add @gpuix/react react
bun add-d @types/react typescript
1. Point TypeScript at the GPUIX JSX types
jsxImportSource is required. Without it TypeScript uses DOM types, so
<virtual-list>, <markdown>, <code> and style.hover all fail to
typecheck.
The binary carries the renderer, so it runs with no Bun and no Node install.
Start from the example app
example-app/ is a complete todo app in one file, with dev,
build, web:dev and typecheck scripts already wired. Copy the folder,
change @gpuix/react from workspace:^ to a version range, and run
bun install.
macOS may block the unsigned binary the first time. Right-click the file, choose Open, and confirm. Windows: download example-chat-x86_64-pc-windows-msvc.exe and double-click it.
The web example bundles the same React app and reconciler as the desktop chat
example. wasm-bindgen exposes the mutation interface to the existing retained
tree and GpuixView, which run through GPUI's browser platform. Browser event
callbacks are not supported yet.
The web build needs nightly Rust and the matching wasm-bindgen CLI:
123rustup toolchain install nightly --component rust-src --target wasm32-unknown-unknown
cargoinstall wasm-bindgen-cli --version0.2.127 --locked
bun run web
The generated Wasm uses shared memory. Production servers must include these
headers on the page, JavaScript, and Wasm responses:
The chat example puts a virtualized <diff> and a GFM table inside an assistant
turn, inside a scrolling transcript:
Markdown, code and a virtualized diff in one frame:
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's reconciler sends individual DOM-like mutations (createElement, appendChild, setStyle, etc.) directly to Rust, with no JSON tree serialization. Rust maintains a retained element tree that GPUI reads each frame.
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 host 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 calls build_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 host surface between JS and Rust is the NativeRenderer interface. Desktop uses napi calls and the browser uses wasm-bindgen methods:
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. commitMutations() flushes that accepted commit and marks the Rust view dirty for the next frame.
Event Flow
On desktop, events travel from GPUI back to React through a ThreadsafeFunction callback. Browser event callbacks are not connected yet.
12345678910111213141516171819User 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 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.
Building
This section is for working on GPUIX itself. To build an app with it, see
Quickstart instead. Installing the packages needs no Rust
toolchain and no submodule.
Prerequisites
Rust toolchain
Node.js 18+
Xcode with Metal Toolchain (macOS)
1234567891011121314151617181920# Install Metal Toolchain if needed
xcodebuild -downloadComponent MetalToolchain
# Install dependencies
bun install# Check out the pinned GPUI forkgit submodule update --init--recursive# Build native packagecd packages/native
bun run build
# Build React packagecd../react
bun run build
# Run example (use tmux for long-running sessions)cd../../examples
bun --hot counter.tsx
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. Waku uses (16, 17)
transparent
boolean
Same as windowBackground: "transparent" when that option is unset
Call it again after a save and it remounts the tree on the same window.
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.
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.
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.
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.
123cd examples
THROTTLE=utility bun run test:perf
THROTTLE=utility bun --hot chat.tsx
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.
On macOS, startFrameLoop calls renderer.tick() at a fixed rate (~125fps by
default). This pumps AppKit on the process main thread without blocking Node. Pass
{ frameMs } to change the rate, and call .stop() on the returned handle to end it.
On Windows and Linux, GPUI runs its normal blocking native event loop on one
dedicated Rust UI thread. Node sends in-process commands to that thread, so
startFrameLoop returns a no-op handle and does not create a JavaScript timer.
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. Windows runtime validation is pending.
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.
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.
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 can freeze the native motion clock and render
specific timestamps. This avoids timer sleeps and gives CI the same frames on
every run.
Containers with overflow: "scroll" become natively scrollable. GPUI handles scroll physics, clipping, and offset persistence automatically.
Plain scroll containers still build every child. Use <virtual-list> below when the collection can grow large.
Important
Nested scrolling is not supported. One parent may scroll. An inner
overflow: "scroll", <virtual-list>, or <diff> 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.
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:
1234567891011121314151617181920212223functionProgrammaticScroll(){const listRef =useRef<any>(null)constjumpToBottom=()=>{if(listRef.current){
renderer.scrollTo(listRef.current.id,0,-999)}}return(<><divref={listRef}style={{ height:200, overflow:'scroll'}}>{items.map((item, i)=><divkey={i}>{item}</div>)}</div><divonClick={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
Virtual lists
Use <virtual-list> 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.
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.
12345678910111213React 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.
123456789index: 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:
A row can contain nested <div>, <text>, <markdown>, <code>, <diff>, <input>, and <textarea> elements. Focusable rows stay active when they move offscreen, so keyboard input and native editor state are preserved. Those children must not scroll. Nested scrolling is not supported; see Scrolling.
Chat tail behavior
Combine alignment="bottom" and followTail for a chat thread:
The list follows new rows while the user is at the bottom. Scrolling upward pauses tail following. Returning to the bottom enables it again. A streaming final row is remeasured as its content grows.
Programmatic scrolling
Use a ref to call the same renderer scroll methods as a plain scroll container:
scrollTo, scrollToItem, and getScrollOffset all support virtual lists.
Performance model
Work
Plain scroll container
<virtual-list> children
VirtualList + itemCount
React Fiber nodes
All rows
All rows
Visible window
Rust retained nodes
All rows
All rows
Visible window
GPUI row construction
All rows
Visible rows plus overdraw
Visible rows plus overdraw
Layout and paint
All rows
Visible rows plus overdraw
Visible rows plus overdraw
Height metadata
None
One lightweight entry per row
One lightweight entry per logical row
VirtualList with itemCount and renderItem mounts only the visible window. Use that for long transcripts. A 10,000-row turns.map still creates every React child. Collections with millions of rows still need application-level paging or a data-owning native element.
Keep scroll fast
A wheel event notifies the window view. GPUI then rebuilds the visible
rows and Taffy lays them out again. Draw time is the cost of those rows, not
the length of the list.
Put a long list on <virtual-list>. Keep overdraw near one extra
viewport. Put fat content in one native node (<markdown>, <code>, <diff>),
not a tree of React spans.
The host <virtual-list> still retains every React child. Pass itemCount
and estimatedItemHeight with renderItem through VirtualList so mount
only creates the window. Native ignores itemCount when the estimate is
missing, so a jump cannot collapse unmounted rows to height 0.
turns is a new array only when a message arrives. Sidebar and draft updates
leave that reference alone, so memo skips the map. The chat example uses
this pattern.
overflowX: "scroll" on a wide child must not steal the vertical wheel.
GPUIX sets restrict_scroll_to_axis on that path. Native
overflow_x_scroll() must call the same method.
Turn on debugFrameOverlay: 'full' while you scroll. The overlay is draw
time. 8.3 MS is about 120 Hz.
Text input
<input> and <textarea> use GPUI's platform input handler. They support a
native caret, text selection, IME composition, clipboard actions, undo/redo,
grapheme-safe deletion and mouse positioning.
Enter emits onSubmit. In a <textarea>, Shift+Enter inserts a newline.
The editor updates natively first, then reports the complete value to React.
value changes can replace the native content, but keeping the same prop value
does not reject an edit like a browser-controlled input.
The focused caret stays solid during edits and then blinks every 500ms while
idle. It stops scheduling repaint frames on blur or while the window is
inactive. Override its colour through the shared native theme:
1<inputtheme={{ caret:'#22c55e'}}/>
Focus and keyboard navigation
Focus is a native GPUI concept. GPUIX connects stable React element IDs to
persistent gpui::FocusHandle values, so focus survives React rerenders:
1234567React <div tabIndex={0}>
│
▼
Retained element ID ► persistent gpui::FocusHandle ► keyboard/action dispatch
▲
│
React rerenders
Inputs and textareas join the normal tab order automatically. Add tabIndex to
a div when it should receive keyboard focus:
Skipped by Tab, but focusable by click or renderer API
autoFocus
Takes focus once, when its native focus handle is created
Tab calls GPUI's window.focus_next(). Shift+Tab calls
window.focus_prev(). This navigation stays in Rust and does not make a
JavaScript round trip.
Use a ref for imperative focus:
1234567const buttonRef =useRef<{ id:number}>(null)functionfocusButton(){if(buttonRef.current) renderer.focusElement(buttonRef.current.id)}<divref={buttonRef}tabIndex={-1}>Focused on demand</div>
Adding onKeyDown, onKeyUp, onFocus, or onBlur creates a persistent focus
handle. Add tabIndex as well when the element must be reachable with Tab.
Removing tabIndex removes the element from the tab order.
Headless controls
The built-in controls are unstyled primitives, not a fixed component
library. Use them like Radix primitives in shadcn: import a primitive namespace,
wrap and style it in a local file, then import those local components throughout
the app.
12@gpuix/react/select ► components/ui/select.tsx ► application screens
native behavior local styles/variants product-specific use
Each primitive has a dedicated namespace entry point:
Import
Main parts
@gpuix/react/select
Root, Trigger, Value, Content, Item
@gpuix/react/combobox
Root, Input, Content, List, Item, Empty
@gpuix/react/tooltip
Provider, Root, Trigger, Content
Build a local Select
Create components/ui/select.tsx. This file is application code, so it can be
copied and changed without waiting for GPUIX to add a theme option:
Use the styled local file with the familiar shadcn shape:
1234567891011121314151617181920import{Select,SelectContent,SelectGroup,SelectItem,SelectTrigger,SelectValue,}from'./components/ui/select'<Selectvalue={model}onValueChange={setModel}><SelectTrigger><SelectValueplaceholder="Select a model"/></SelectTrigger><SelectContent><SelectGroup><SelectItemvalue="sonnet">Sonnet</SelectItem><SelectItemvalue="opus">Opus</SelectItem></SelectGroup></SelectContent></Select>
The trigger participates in normal tab navigation. Opening the Select focuses
its content. Up, Down, Ctrl+P, Ctrl+N, Enter, and Escape control the
menu. Closing it restores focus to the trigger. Disabled items are skipped.
Style Combobox and Tooltip the same way
Start their local files from namespace imports too:
Combobox uses the native input for text editing, IME, clipboard, and focus.
Tooltip asChild preserves the child ref and merges trigger behavior into that
host element. All floating content uses GPUI's deferred anchored() layer,
snaps inside the window, and occludes controls behind it.
Overlay menus
Menus, tooltips, and dialogs must use SelectContent, ComboboxContent,
or <anchored deferred>. Those paint in a later pass, on top of
<virtual-list> and the rest of the page.
A position: "absolute" card that overflows out of the composer sits under
the virtual list. The list paints after the composer, so you still see the
markdown through the menu, and clicks hit the text behind it.
Give every overlay an opaque fill (#232323, not #23232399).
FloatingLayer defaults to #1A1A1A. Item rows should use the same solid
color, or a solid hover color. A #00000000 child on a blurred window punches
through Metal to the desktop.
A filled in-flow div blocks clicks and hovers behind it. The parent
scroller still gets the wheel. position: "absolute" / "fixed" or
pointerEvents: "auto" also steals the wheel. Set pointerEvents: "none"
to pass hits through.
Text selection
Every text GPUIX paints is selectable and copyable, including text inside
<code>, <diff> and <markdown>. A drag that starts in a heading and ends
inside a fenced code block selects everything between; Cmd+C copies it joined in
document order.
There is nothing to opt into. To opt out — toolbars, buttons, line-number
gutters — set userSelect: "none", which inherits like the CSS property:
123<divstyle={{ userSelect:'none'}}><text>toolbar label, never selected</text></div>
Read the selection from the renderer:
12renderer.getSelectedText()// joined text, or null
renderer.clearSelection()
Selection works because each painted text element registers itself into a
per-frame registry in paint order, which is document order. A drag anchored
in one element resolves against that registry into per-element spans: partial in
the anchor and head, whole for everything between.
Why not one big text element, like Zed?
Zed's markdown selects continuously because its whole document is a single
element over one text model. GPUIX renders a tree of text elements, so it
rebuilds that continuity at paint time instead. The mechanism is ported from
Comet (MIT), which faced the same problem.
Native text components
Three elements render text with Syntect syntax highlighting computed in
Rust. Colours come from a theme prop, so a late-arriving highlight recolours runs
without ever changing layout.
<code>
A syntax-highlighted code block. One row per line at an exact line height, so the
block's height is known before highlighting runs.
It paints no surface of its own: no fill, border, radius, padding or language
header. style is the surface, so the card look is yours.
123456789101112<codecode={source}language="typescript"// or path="src/app.ts" to detect from extensionshowLineNumbersstyle={{
padding:12,
borderRadius:10,
borderWidth:1,
borderColor:'#ffffff1f',
backgroundColor:'#ffffff09',}}/>
fontFamily, fontSize, fontWeight, lineHeight and color in style beat
the theme. Rows are a fixed height, so fontSize alone scales that height by the
theme's ratio; pass lineHeight to set it exactly.
Two things stay owned by the element: lines never wrap, and the block is its
own horizontal scroller. A long line pans on a horizontal wheel inside it, so
whiteSpace and overflowX in style do nothing.
For a language header, or any other chrome, wrap it in a <div> you own:
<markdown> is different: it keeps its own fenced-block card, because a document
renderer owns its layout. Tune that card with the mdCode* metrics.
<diff>
A unified diff viewer. It flows with its parent by default, so a parent
list can be the only scroller. Collapsing a file removes its rows rather than hiding
them, so a collapsed 10k-line file costs one row.
Use maxLines to keep a long patch short. Show more fires onShowMore. Clear
maxLines in that handler to reveal the rest.
Pass scroll and a bounded height only for a dedicated full-window viewer.
That path uses GPUI's list() and virtualizes. Do not nest it inside another
scroller. See Scrolling.
123456789<diffpatch={unifiedPatch}wordDiff// highlight only the tokens that changedmaxLines={open ?undefined:24}collapsedPaths={['pnpm-lock.yaml']}onShowMore={()=>setOpen(true)}onToggleFile={(e)=>toggle(e.value)}onLineClick={(e)=>console.log(e.oldLine, e.newLine, e.value)}/>
<markdown>
GitHub-flavoured markdown: headings, lists, tables, block quotes, fenced code,
strikethrough, task lists, and autolinked bare URLs.
All three take the same optional theme prop. Every field layers on top of the
built-in dark theme, so overriding one token leaves the rest alone.
123456789<codecode={source}language="rust"theme={{
appearance:'dark',// or 'light'
accent:'#7c86ff',
syntax:{ keyword:'#f38ba8',string:'#a6e3a1'},}}/>
Layout numbers live in the theme too, under metrics. Row heights, gutter
widths, paddings and the heading scale are props, not Rust constants, so tuning
the design is a React re-render and never a native rebuild.
objectFit matches CSS: "contain" (default), "cover", "fill",
"scaleDown", or "none". An empty src or a failed load shows a fallback
placeholder instead of crashing.
<svg>
<svg> uses GPUI's monochrome icon renderer. Raw source works on desktop
and in the browser. Desktop apps can also use a local src path. The icon is
drawn as one shape and tinted with style.color.
For application icons, prefer raw SVG source. It works with both GPUIX
targets and lets a bundler embed each icon in the JavaScript bundle. Use src
only for a desktop app that intentionally ships loose asset files.
src is a filesystem path or a data:image/svg+xml,… URL. Vitest and some
Bun import … with { type: 'file' } bindings emit the data URL. GPUIX decodes
both.
style.color is required. Without it the icon does not paint. Prefer
fill="#000" or stroke="#000" in the file. currentColor in the SVG is not
the same as style.color.
Bun
Use Bun's text loader. The import
is a string containing the complete SVG, and bun build embeds it in the
bundle.
Node.js also has text modules,
but they currently require --experimental-import-text. Prefer
readFileSync until
text imports no longer need a runtime flag.
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
Change
onChange
value — <input> and <textarea> only
Submit
onSubmit
value — <input> and <textarea> only
Toggle file
onToggleFile
value (file path) — <diff> only
Show more
onShowMore
value (hidden line count) — <diff> only
Line click
onLineClick
value, oldLine, newLine — <diff> only
Link click
onLinkClick
value (URL) — <markdown> only
Keyboard and focus listeners create a persistent GPUI FocusHandle
automatically. A listener alone does not put a div in the Tab order; add
tabIndex={0} for that. Inputs and textareas already use tab index 0.
A node that listens for both onMouseDown and onMouseMovecaptures the
pointer, like HTML setPointerCapture.
onMouseMove and onMouseUp keep firing after the pointer leaves the hitbox.
You do not need a full-window overlay or window listeners to drag a clip or
resize a pane. A node with only onMouseDown / onMouseUp does not capture,
so a click still ends if you release outside.
none components and the parser's limited relative-color from / calc()
forms.
Standard comma and modern space/slash alpha forms work. Values are converted
to hard-clipped sRGB before GPUI paints them. Invalid strings are ignored for
that property; they do not reject the full style object.
hsv(), hsva(), and hwba() are parser extensions rather than CSS Color 4
standard functions. color(), platform/dynamic colors, and numeric color
integers are not accepted.
Selection:userSelect ("text" | "none"), selectionColor — both inherit down the tree
Hover and active
hover and active are nested style objects. GPUI applies them natively
when the pointer is over the element or the mouse is down. There is no
JavaScript round trip.
Nesting is one level deep. A hover object cannot contain another hover or
active.
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:
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).
Automation
Mark elements with testId, then drive them like Playwright. The same
client works in vitest, inside browser pages, and against a child process.
Every browser render installs the automation App as globalThis.gpuix.
It is always available after render() returns. No setup flag or separate
transport is required.
123456789101112await page.evaluate(async()=>{await globalThis.gpuix.getByTestId('sidebar-collapse').click()await globalThis.gpuix.getByTestId('composer').fill('hello from Playwriter')await globalThis.gpuix.clock.pause()await globalThis.gpuix.clock.fastForward(200)})
The browser global supports locators, input, tree and text queries, bounds,
selection, scrolling, focus, and clock control. Browser pages cannot write an
arbitrary local screenshot path. Use the controlling browser tool for that:
click() hits the center of the last painted bounds. fill(text) replaces the
focused editor contents. press('enter') sends one key. waitFor() polls until
exactly one match exists.
Screenshots and clock
app.screenshot({ path }) writes the current GPU frame as a PNG.
app.clock.pause(), set(ms), and fastForward(ms) freeze native motion time.
Use that to capture a sidebar animation at known timestamps:
launch({ command, args }) starts the app and speaks the same commands
over stdin as SSE data: lines. The app listens only when stdin is a pipe,
so a normal terminal run is unchanged. Lines without a data: prefix are
ignored; console.log cannot break a message.
The locators above sit on a GPU-backed test renderer (TestGpuixRenderer).
It runs the same GpuixView, build_element(), apply_styles(), and event
handlers as production. Windows are positioned offscreen but fully rendered by
Metal. The methods below are the lower-level API when a locator is not enough.
getAllText() only sees <text> nodes in the retained tree. <code>, <diff>
and <markdown> paint their text inside GPUI, so use getPaintedText(), which
returns every string painted in the last frame in paint order:
Selection has its own helper. Listeners are registered during paint, so
dragSelect flushes between every step; calling simulateMouseDown / Move /
Up by hand without those flushes selects nothing:
Screenshots land in packages/react/screenshots/ and examples/screenshots/,
both gitignored, so they can be inspected after a run without adding a binary
diff to every commit. The curated set the README links to lives in
docs/images/ and is regenerated with:
1bun scripts/screenshots.ts
Developing the Rust side
JS remount is covered above. There is no hot reload for the native half,
and there cannot be: require() of a .node file calls process.dlopen, Node
has no matching unload, and the live state (GPUI's platform, GPU device, open
window, UI thread, and selection registry) stays inside the loaded library. A
second load would create independent native state while the first library
remains loaded.
The rebuild is fast enough that it does not matter. Measured on an M-series Mac
after touching one file:
Step
Time
cargo check --lib
1.5s
cargo build --lib
4.9s
bun run build:debug (napi)
~2s
One vitest screenshot file
~2s
bun run dev wires that into a loop: it watches packages/native/src,
rebuilds, and re-renders the screenshot tests. Rust edit to fresh PNGs is
about 4 seconds.
123bun run dev # rebuild, re-render the showcase screenshots
bun scripts/dev.ts --shotsdiff# only tests matching "diff"
bun scripts/dev.ts --app native-text # rebuild, restart an example app
Screenshot mode is the better default. Open
packages/react/screenshots/showcase.png in Preview.app, which reloads on
write, and unlike a live window the PNG can also be read by an agent.
Two things avoid the rebuild entirely:
Content already lives in props. Change patch or source and the next
frame shows it.
Design numbers live in theme.metrics. Tuning a row height or heading
scale is a React re-render.
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.