bevy-react 0.1.0

Drive bevy_ui from a React app over an embedded V8 runtime.
docs.rs failed to build bevy-react-0.1.0
Please check the build logs for more information.
See Builds for ideas on how to fix a failed build, or Metadata for how to configure docs.rs builds.
If you believe this is docs.rs' fault, open an issue.
Visit the last successful build: bevy-react-0.1.2

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/

The bevy-react demos app: a React-driven left-nav over a live 3D Bevy scene, with a world-tracking "Bounces" panel anchored above a bouncing ball.

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.

Why bevy-react

  • 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_ui entities 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.

How it works

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_ui entities. 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.

Project status

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 compatibility

bevy bevy-react
0.19 0.1

The demos app

examples/demos is a gallery that exercises every feature above, with a left-nav that switches between live demos. 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 demos

Getting started

Scaffold the React UI for a new project in one command:

npx bevy-react init ui   # creates ui/ with package.json, tsconfig, build, and a starter App

examples/minimal is the smallest end-to-end project - the Rust host, a scaffolded React app, bundling, and typed bindings - and a good template to copy from.

bevy-react is a Rust crate (bevy-react) plus an npm package (bevy-react), developed together. Both are 0.1.0 and not yet published, so for now you depend on them by path or git.

Features

Elements & styling

Host elements <node>, <button>, <text>, <image>, <editableText>, <canvas>, <portal>, and <surface> cover layout, input, drawing, 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>

Hover & press states

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>

Pointer & drag

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()}
/>

Transitions

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",
    transform: { translateX: on ? 36 : -36 },
    transition: {
      transform: { stiffness: 180, damping: 14 }, // spring
      backgroundColor: { duration: 200 }, // timing (ms)
    },
  }}
>
  <text>{on ? "ON" : "OFF"}</text>
</button>

Animations

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 through animatedStyle on an Animated.* element.

import { Animated, useSharedValue, withRepeat, withTiming } from "bevy-react";
import { useEffect } from "react";

function Pulse() {
  const opacity = useSharedValue(1);
  useEffect(() => {
    opacity.value = withRepeat(
      withTiming(0, { duration: 500, easing: "easeInOut" }),
      -1, // repeat forever
      true, // ping-pong
    );
  }, [opacity]);

  return (
    <Animated.node
      style={{ width: 80, height: 80 }}
      animatedStyle={{ opacity }}
    />
  );
}

Drivers: withTiming, withSpring, withRepeat, withSequence, withDelay, plus interpolate / interpolateColor to map one value through a curve.

Fonts

Register a font on the host, then select it by name in any <text> style.

// Font paths are relative to your asset root (`assets/` by default).
ReactUiPlugin::new("ui/dist/app.js").font("DancingScript", "fonts/dancing.ttf")
<text style={{ fontFamily: "DancingScript", fontSize: 34 }}>Fancy</text>

Canvas drawing

<canvas> takes a draw callback with an HTML-canvas-like context; the result is rasterized into a texture. Returning fresh drawing each render makes it reactive. Uses tiny_skia as a rendering backend.

<canvas
  style={{ width: 460, height: 260 }}
  draw={(ctx) => {
    ctx.strokeStyle = "#89b4fa";
    ctx.lineWidth = 2;
    ctx.beginPath();
    ctx.moveTo(0, 150);
    ctx.bezierCurveTo(100, 0, 200, 150, 300, 20);
    ctx.stroke();
  }}
/>

Render-target portals

<portal> shows an offscreen render target inside the UI — the live (or snapshot) output of a Bevy camera rendering into a texture. The app registers a named target and aims a camera at it; React displays it by name. Good for minimaps, picture-in-picture, or per-item 3D previews.

// Bevy: register a target, then point a camera at it.
let view = render_targets.create(&mut images, "follow", RenderTargetSpec::default());
commands.spawn((Camera3d::default(), view.camera_target(), PortalCamera("follow".into())));
// React: show it by name (Auto-sized to the node, so it stays crisp).
<portal target="follow" style={{ width: 160, height: 160 }} />

A "follow" portal showing an offscreen chase-cam view of a wandering cube and a 2D minimap of the whole field, each rendered by a Bevy camera into a texture and displayed in the React UI.

Surfaces: UI on a 3D mesh

<surface> is the inverse of <portal>: instead of showing a 3D camera inside the UI, it renders a React subtree into an offscreen texture that the Bevy app drapes onto any 3D mesh — a diegetic monitor, panel, or hologram driven by live React. Tag the displaying mesh with SurfacePointer to make the subtree clickable in 3D, so onClick/onPointer* and hover/press styles fire from in-world pointer hits.

// Bevy: register a surface, use its texture on a mesh, make the mesh clickable.
let screen = surfaces.create(&mut images, "monitor", SurfaceSpec { size: UVec2::new(760, 700), ..default() });
material.base_color_texture = Some(screen);
commands.entity(screen_mesh).insert(SurfacePointer::new("monitor"));
// React: render a subtree into the named surface's texture.
<surface name="monitor" style={{ width: "100%", height: "100%" }}>
  <MonitorApp />
</surface>

A 3D monitor model whose screen is a live React "OS" — menu bar, taskbar, status line, and a code viewer — rendered into an offscreen texture and clickable in 3D.

World-anchored overlays

Pin UI to a 3D entity so it tracks the entity on screen as the camera moves.

import { Anchored } from "bevy-react";

<Anchored.node entity={cube} offset={[0, 1, 0]} style={{ padding: 8 }}>
  <text>Label</text>
</Anchored.node>;

Dozens of colored cubes in a 3D scene, each with a numbered React badge anchored above it that tracks its cube as the camera moves.

Talking to Bevy

Three typed channels connect React and the ECS:

  • Notify - bevy.foo.doSomething(value): React -> Bevy event
  • Request - await bevy.foo.getSomething(): request/response cycle
  • Subscribe - bevy.on(eventName, callback): Bevy → React events

1. Define the channel in Rust with a macro and register it on the App:

use bevy::prelude::*;
use bevy_react::{ReactAppExt, ReactEvents, react_event, react_message};

// React → Bevy: `bevy.game.reset()`.
#[react_message(name = "game.reset")]
struct Reset;

fn on_reset(_: On<Reset>, /* queries, resources… */) {
    // reset the game
}

// Bevy → React: `bevy.on("game.scored", …)`.
#[react_event(name = "game.scored")]
struct Scored;

fn award_point(events: ReactEvents) {
    events.send(&Scored);
}

app.add_react_handler(on_reset);
app.add_react_event::<Scored>();

2. Generate the typed client that React imports from ./bevy. Add an export path to your app - typically a flag that builds the App, registers your channels, and calls app.export_react_typescript("ui/src/bevy.ts") (see examples/minimal/main.rs) - then run it (re-run whenever you add or change a channel):

cargo run -- --export-bindings ui/src/bevy.ts

3. Use it from React:

import { bevy } from "./bevy";
import { useEffect, useState } from "react";

function Score() {
  const [hits, setHits] = useState(0);

  useEffect(() => bevy.on("game.scored", () => setHits((h) => h + 1)), []);

  return (
    <button onClick={() => bevy.game.reset()}>
      <text>{`Hits: ${hits}`}</text>
    </button>
  );
}

The request channel (#[react_request] - React awaits a typed reply) works the same way; examples/demos defines all three channels across its demos.

Performance

The bridge is delta-based and batched, so steady-state updates are cheap: changing one row of a 1k-row table costs ~2.4 ms end to end, and creating all 1k rows from scratch ~52 ms (Ryzen 7 5800X / RTX 3070). Full methodology and per-operation tables (1k and 10k rows) live in docs/BENCHMARKS.md.

License

Dual-licensed under either of Apache License 2.0 or MIT license, at your option.