Build bevy_ui interfaces with
React. You write components in React/TSX and they render to native Bevy UI
through a React Native-style bridge - no web view, no DOM. The JS side stays
purely declarative; Rust and Bevy do the heavy lifting. State and interactions flow
both ways between your Bevy app and React, and edits hot-reload live while keeping
component state.
You can play with a live demo here:
https://tulustul.github.io/bevy-react/
import { mount } from "bevy-react";
import { useState } from "react";
function App() {
const [n, setN] = useState(0);
return (
<node style={{ padding: 20, gap: 12, flexDirection: "column" }}>
<text>{`Count: ${n}`}</text>
<button
onClick={() => setN((c) => c + 1)}
style={{ backgroundColor: "#7aa2f7" }}
>
<text>+</text>
</button>
</node>
);
}
mount(<App />);That's a real component - <node> and <button> render to actual bevy_ui
nodes, useState works as you'd expect, and saving the file updates the running
app without losing the count.
- React, not a bespoke UI DSL. Hooks, components, conditional rendering, lists - everything you already know.
- Native Bevy UI. No web view, no DOM. Your UI is
bevy_uientities in the same world as your game. - Hot reload that keeps state. Edit a component and it re-renders live with hook state and running animations intact.
- Typed, two-way messaging. React and the ECS talk over typed channels generated straight from your Rust types.
bevy-react uses a bridge architecture, much like old versions of React Native - but the native side is Bevy and the ECS instead of iOS/Android views.
- React runs on embedded V8. On native targets the JS runs in a V8 isolate via
deno_core- no Node, no browser - on its own thread, off the game loop. - Web builds work too. On wasm the same bundle runs in the browser's own JS
engine instead of V8; the UI is still
bevy_ui, not DOM. The live demo is the web build. - JS only describes the UI. React renders through a custom reconciler that emits
declarative UI-mutation ops; Rust applies them to
bevy_uientities. All the heavy lifting - layout, input, rendering - happens in Rust and Bevy. - Animations are orchestrated in Bevy, not JS. Shared values and transitions are driven on the Bevy side every frame; JS just declares the target. No per-frame JS, no bridge traffic per tick.
Currently, the project is a quick, vibecoded proof of concept demonstrating the idea. The API is very unstable and will change, the code quality is not satisfying. Do not use it in production.
| bevy | bevy-react |
|---|---|
| 0.19 | 0.1 |
cargo add bevy-reactScaffold the React UI:
npx bevy-react init ui
cd ui && npm run watchAdd the plugin to your app
use bevy_react::{ReactUiPlugin};
app.add_plugins(ReactUiPlugin::new("ui/dist/app.js")Follow the examples/minimal example for a full working setup.
Copy the --export-bindings flag implementation from examples/minimal
After that you can run
npm run bevy:generateor
cargo run -- --export-bindings ui/src/bevy.tswhich will generate a bevy.ts file in your ui directory. This file will include all the needed integration with your Rust code. See Talking to Bevy for details.
Rembember to regenerate the client each time you update the communication channel in Rust.
examples/demos is a gallery that exercises most features available. It's the best reference implementation - each demo is a small, self-contained component you can read and copy when wiring up your own UI, messaging, or animations.
npm install
npm run build -w demos
cargo run --example demosHost elements <node>, <button>, <text>, <image>, <editableText>,
<canvas>, <svg>, <portal>, and <surface> cover layout, input, drawing,
vector graphics, embedded 3D views, and UI rendered onto 3D meshes.
Style them with a flexbox/grid object (colors, spacing, borders, radius, shadows,
transforms).
<node
style={{
flexDirection: "row",
gap: 16,
padding: 20,
backgroundColor: "#1e1e2e",
borderRadius: 8,
}}
>
<text style={{ fontSize: 18, color: "#cdd6f4" }}>Hello</text>
</node>Overlay extra style while an element is hovered or pressed - no state wiring needed.
<button
onClick={() => save()}
style={{ backgroundColor: "#7aa2f7" }}
hoverStyle={{ backgroundColor: "#89b4fa" }}
pressStyle={{ backgroundColor: "#5a7fd6" }}
>
<text>Save</text>
</button>onPointerDown / onPointerMove / onPointerUp give you drag gestures, with both
element-normalized (x, y) and window (clientX, clientY) coordinates.
<node
onPointerDown={(e) => start(e.clientX, e.clientY)}
onPointerMove={(e) => drag(e.clientX, e.clientY)}
onPointerUp={() => drop()}
/>Ease changes to a style by listing which properties should animate, with timing or spring config.
<button
onClick={() => setOn((v) => !v)}
style={{
backgroundColor: on ? "#a6e3a1" : "#45475a",
borderRadius: on ? 24 : 6,
transform: { translateX: on ? 36 : -36 },
transition: {
transform: { stiffness: 180, damping: 14 }, // spring
backgroundColor: { duration: 200 }, // timing (ms)
borderRadius: { duration: 200 }, // per corner
},
}}
>
<text>{on ? "ON" : "OFF"}</text>
</button>transition: { layout } eases a node to wherever layout puts it next — whatever
moved it: a reorder, a sibling growing or leaving, a parent resize, a flipped
flex knob. The real layout snaps; the box glides from its old rect to the new
one (FLIP), children ride along, and clicks land on the visual. Nothing to
measure, no transforms to write.
<node style={{ flexDirection, justifyContent, alignItems }}>
{swatches.map((g, i) => (
<node
key={i}
style={{
width: 40,
height: 40,
backgroundGradient: g,
transition: { layout: { duration: 350, easing: "easeInOut" } },
}}
/>
))}
</node>A size change eases the node's own box (its children stay crisp), but
whatever is laid out around it snaps — a container that must re-flow its
neighbours uses the real-layout transition: { size } instead, with layout
on the children. Demos: "Flexbox", "Style transitions".
Give two nodes that swap in one commit the same sharedTag — a thumbnail in a
grid and the hero of the detail screen that replaces it — and the incoming node
starts where the outgoing one visually was (position, size, background color,
opacity, transforms, filters, gradients) and eases to its own layout and style.
React has no reparenting, so a "move" between parents is always an unmount plus
a mount: the tag is the identity, and the commit itself is the trigger — no
hooks, no measuring, no start call.
// grid
<image src={item.thumb} sharedTag={`hero-${item.id}`} style={{ width: 72, height: 72 }} />
// detail, mounted in the commit that unmounts the grid
<image
src={item.full}
sharedTag={`hero-${item.id}`}
style={{
width: 200,
height: 200,
transition: { sharedElement: { duration: 450, easing: "easeInOut" } },
}}
/>transition: { sharedElement } on the incoming node is the one timing for every
seeded channel (required — a tag without it pairs but snaps). Pairs need the same
tag, element type and UI root; the first mounted match seeds every incoming node
with the tag. Size flies in measured pixels through real layout (children stay
crisp, the parent re-flows), position by translation; the outgoing node unmounts
instantly. Demo: "Shared elements".
Shared elements and layout animations compose. On the tickets board below a
clicked ticket unmounts from one column and mounts in the other in the same
commit: it carries sharedTag plus both transitions, so it takes off from
where it sat, easing its width and color to the new column's on the way, while
the siblings it leaves behind close the gap with their own layout transition.
<button
sharedTag={`item-${id}`}
onClick={() => move(id)}
style={{
backgroundColor: color[side],
transition: {
sharedElement: { duration: 400, easing: "easeOut" },
layout: { duration: 400, easing: "easeOut" },
},
}}
/>For richer motion, use Reanimated-style shared values driven on the Bevy side (no
per-frame JS). Create a value with useSharedValue, assign it a driver, and bind
it inline in style with the { animated: … } wrapper — on any plain
element, in any animatable position (opacity, colors, layout lengths, transform
channels, transform3d fields, filter params).
import { useSharedValue, withRepeat, withTiming } from "bevy-react";
import { useEffect } from "react";
function Pulse() {
const opacity = useSharedValue(1);
useEffect(() Scan report · 2026-09-11 - ✓ Prohibited terms or links
- ✓ Repository eligibility
- ✓ slopscore.md paperwork
- ✓ Content policy
- ✓ Risk review
report this listing
— log in to report




0 comments
log in to comment.