Custom elements

An app can register its own JSX elements in Rust. The API is the one every built-in element uses: <svg>, <canvas>, <portal>, <surface> and <anchor> are each a crate registering one. An element declares its attributes (its own props), the writers that turn them into components, its events and a spawn hook. React then mounts, updates and unmounts entities of that kind like any other. An element doesn't have to be UI: the demos' <cube> is a 3D mesh in the world.

Usage

Declare the attribute and the element as statics, register the element, and render it from React:

TSX

<node>
  {cubes.map((c) => (
    <cube key={c.id} x={c.x} size={0.4} />
  ))}
</node>

Rust

use bevy::prelude::*;
use bevy_react::element::{Attribute, Common, Element};
use bevy_react::ext::ElementFlags;
use bevy_react::style::{Writer, owns};
use bevy_react::{ReactAppExt, ReactApplySet};

#[derive(Component)]
struct Cube;

static X: Attribute<f32> = Attribute::new("x");
static SIZE: Attribute<f32> = Attribute::new("size");

static CUBE: Element = Element {
    // No `Node`, and never parented under its React parent.
    flags: ElementFlags { detached: true, ..ElementFlags::NODE_LESS },
    attrs: &[&X, &SIZE],
    common: Common::IDENTITY,
    writers: &[&CUBE_WRITER],
    spawn: Some(|ctx| ctx.spawn((Cube, Transform::default()))),
    ..Element::new("cube")
};

// Attributes → components. Runs when `x` or `size` changes.
static CUBE_WRITER: Writer = Writer {
    reads: &[],
    attrs: &[&X, &SIZE],
    writes: &[owns::<Transform>],
    apply: |ctx, _style, ec| {
        let x = ctx.attr(&X).copied().unwrap_or(0.0);
        let size = ctx.attr(&SIZE).copied().unwrap_or(1.0);
        ec.insert(
            Transform::from_xyz(x, 0.0, 0.0)
                .with_scale(Vec3::splat(size)),
        );
    },
};

pub struct CubePlugin;

impl Plugin for CubePlugin {
    fn build(&self, app: &mut App) {
        register_bindings(app);
        app.add_systems(Update, add_meshes.after(ReactApplySet));
    }
}

/// Also called from the `--export-bindings` path.
pub fn register_bindings(app: &mut App) {
    app.add_react_element(&CUBE);
}

fn add_meshes(
    mut commands: Commands,
    cubes: Query<Entity, Added<Cube>>,
    mut meshes: ResMut<Assets<Mesh>>,
    mut materials: ResMut<Assets<StandardMaterial>>,
) {
    for entity in &cubes {
        commands.entity(entity).insert((
            Mesh3d(meshes.add(Cuboid::from_length(1.0))),
            MeshMaterial3d(materials.add(StandardMaterial::default())),
        ));
    }
}

Then regenerate bevy.ts (see Typing) so <cube> type-checks. The complete element, with animated attributes and pointer events, is examples/demos/cube.

The element

Element is a plain struct. Start from Element::new("name") (a plain styled node with every common prop) and override fields with struct-update syntax:

Field Meaning
name The JSX intrinsic name
flags What the entity is (see Flags); default ElementFlags::NODE
attrs The element's attributes, at most 64
required Attributes the generated typing marks required (must be listed in attrs)
common Which shared prop groups apply (see below); default Common::ALL
writers The element's own writers, in apply order
suppress Global style writers this element opts out of
events The element's own events
default_style fn() -> Style: the style the user's style overlays
spawn The spawn hook; None spawns the entity with nothing extra
ts_ref The TypeScript type a ref resolves to (only for runtime-provided handles)

The props every element shares are not attributes. common picks the groups that apply; a shared prop outside them is ignored with a propIgnored warning:

Group Props
Common::IDENTITY name, sharedTag
Common::VARIANTS hoverStyle, pressStyle, focusStyle
Common::POINTER onClick, onPointerEnter, onPointerLeave, …
Common::SCROLL onScroll, scrollTop, scrollLeft, scrollStep
Common::WHEEL onWheel

Combine groups with .with(..). default_style builds a Style with Style::set and the core property statics in bevy_react::style::props (the built-in <button> sets FOCUS_POLICY to block). Unsetting a property in JSX falls back to the default style's value.

Attributes

An attribute is a static Attribute<T>. The static is the declaration and also the typed key you read the value with: ctx.attr(&SIZE) in a writer, ctx.props.attrs.get(&SIZE) in the spawn hook. Declare attributes static, never const: they are identified by address.

  • Attribute::new(name) decodes through T's serde Deserialize and types the prop as T's ts-rs type, so T derives both (serde and ts-rs 10, the version bevy-react uses). Attribute::with_codec(name, codec) takes the same codecs as a style property (see codecs).
  • Attributes are per element. Two elements may declare the same name with different types, or share one static. A prop the element doesn't declare is dropped with an unknownProp warning.
  • Names reserved for shared props, children, key, ref, and anything shaped like a handler (on followed by an uppercase letter) can't be attributes; registering one panics.
  • Updates are deltas: a writer runs when an attribute it reads changes, and sees the merged value. A removed prop reads as None.
  • event: true makes an act-now attribute: a command, not state. It is never retained, a writer sees it only on the update that carries it (ctx.event(&ATTR)), and removing it from JSX does nothing. <canvas>'s draw is one.
  • invalidate: Invalidation::PAINT marks a change as repainting the node, so an enclosing cached layer re-captures. Set it on any attribute that changes what a UI element paints.
  • typed: false keeps a wire-only attribute out of the generated JSX types.

A value that fails to deserialize fails the whole commit: the React commit throws a TypeError and Bevy never receives its ops. The generated types are the guard; for lenient decoding, write a custom codec that reports the value and returns Ok(None).

Spawn hook and writers

The spawn hook creates the entity with the components it is born with. Always spawn through ctx.spawn(bundle): it adds the bridge identity and, for an element with a box, the style components in one bundle. SpawnCtx also carries commands, images, assets, the create op's props, the effective style, kind and id; ctx.blank_image() makes a placeholder texture for an element that paints its own image.

Writers do everything prop-derived. A Writer declares what it reads and what it writes:

  • reads lists style properties and attrs lists attributes. A change to any of them re-runs the writer. On a freshly spawned entity (ctx.fresh), it runs only if one of them is set.
  • apply gets the full merged values, never a delta: an absent value means remove or reset.
  • writes lists the components it owns (owns::<C>). Two writers of one element writing the same component panics at registration. An element writer that writes a component a global style writer also writes takes over that component on this element; suppress opts out of a global writer explicitly, and a style property only suppressed writers read is ignored with a styleIgnored warning.
  • Compare before you write (queue an entity command that inserts only on a real change) when your systems react to Changed<C>.

There is no separate update function: creates and updates both go through the writers. React's own mount, update and unmount of the element are the only lifecycle; unmounting despawns the entity and its Bevy children.

Flags

ElementFlags tells the core's shared systems what the entity is:

Constant Entity
NODE A styled UI node (the default)
OWNS_IMAGE A styled node whose ImageNode the element owns (backgroundImage leaves it alone)
NODE_LESS No Node: no style, no layout box, never a composited layer
DETACHED_ROOT A styled node that becomes its own UI root, like <root> and <surface>

detached: true means the core never attaches the entity in the Bevy hierarchy: it records the React parent, so unmounting an ancestor still despawns it, and leaves the Bevy parent to you. A non-UI entity such as a mesh needs it, since a UI node is no transform parent for a mesh. pick_ignore: true makes the entity itself never a picking target.

Element events

An element can send its own events to React. Declare a static ElementEvent<T>, list it in events, and send it with the ElementEvents system param. The handler prop is on plus the capitalized name, and its argument is the payload:

TSX

<cube onLanded={({ speed }) => setLastSpeed(speed)} />

Rust

use bevy_react::element::{ElementEvent, ElementEvents};

#[derive(serde::Serialize, ts_rs::TS)]
struct Landed {
    speed: f32,
}

static LANDED: ElementEvent<Landed> = ElementEvent::new("landed");
// In the element: `events: &[&LANDED],`

fn report_landings(events: ElementEvents, cubes: Query<(Entity, &Fall)>) {
    for (entity, fall) in &cubes {
        if fall.just_landed {
            events.send(entity, &LANDED, &Landed { speed: fall.speed });
        }
    }
}
  • send delivers only to nodes that have a handler, and returns whether it sent. ElementEvent::new(..).unconditional() sends regardless, for an event a runtime helper consumes itself.
  • A () payload makes a handler with no argument.
  • An event name whose handler prop collides with a shared prop (onClick) panics at registration.

Animated attributes

An attribute can accept an inline { animated } binding to a shared value, driven every frame on the Bevy side without re-rendering React. Give it an Animatable<T> value and an AttrBinding naming a domain:

TSX

function PulsingCube() {
  const t = useSharedValue(0);
  useEffect(() => {
    t.value = withRepeat(withTiming(1), { reverse: true });
  }, [t]);
  return (
    <cube
      size={{ animated: interpolate(t, [0, 1], [0.6, 1.2]), seed: 0.6 }}
      color={{
        animated: interpolateColor(t, [0, 1], ["#7aa2f7", "#f7768e"]),
      }}
    />
  );
}

Rust

use bevy_react::animations::AnimationSet;
use bevy_react::element::{AttrBinding, animatable_binding};
use bevy_react::ext::DrivenExtValues;
use bevy_react::protocol::animatable::Animatable;
use bevy_react::style::Codec;

static SIZE: Attribute<Animatable<f32>> = Attribute {
    animated: Some(AttrBinding {
        domain: "cube",
        binding: animatable_binding::<f32>,
    }),
    ..Attribute::with_codec("size", Codec::serde_as("Animatable<number>"))
};

// Ordered `.after(AnimationSet::Apply)`.
fn drive_sizes(
    mut cubes: Query<
        (&DrivenExtValues, &mut Transform),
        Changed<DrivenExtValues>,
    >,
) {
    for (driven, mut transform) in &mut cubes {
        if let Some(size) = driven.get("cube", "size") {
            transform.scale = Vec3::splat(size);
        }
    }
}
  • The engine evaluates each binding and publishes the result into the entity's DrivenExtValues under (domain, attribute name), only when the value changes. Read it after AnimationSet::Apply to apply it the same frame: get returns a number, get_color an Srgba (from an interpolateColor binding), value either.
  • The binding picks the kind, so an attribute of any type can animate. Checking that the kind fits the attribute is your system's job; the demo cube reports a mismatch as a styleBinding warning.
  • While bound, Animatable::value() is None and seed() is the wrapper's seed: use it until the first value is published. A binding to a missing shared value publishes nothing.
  • Attribute changes don't ease on their own: there is no transition for attributes unless your element implements one.

Pointer events

A styled element takes the pointer handlers like <node>. A node-less element needs help from your code:

  • onClick fires for any picking hit on the entity, so bring a picking backend that sees it (Bevy's MeshPickingPlugin for a mesh).
  • onPointerEnter and onPointerLeave follow the entity's Interaction. The core inserts one on a node-less element while it has pointer handlers, but bevy_ui only updates it for UI nodes: drive it yourself in a system in InteractionSyncSet, as the demo cube does from the hover map.
  • The drag handlers (onPointerDown, onPointerMove, onPointerUp) are only delivered to UI nodes; a node-less element never receives them.

System ordering

Order your element's systems by the public sets, never by core function names:

Set Schedule Use it to
ReactApplySet Update Run .after it to see this frame's mounts and attribute changes
AnimationSet::Apply Update Run .after it to read this frame's DrivenExtValues
InteractionSyncSet Update Write Interaction for entities bevy_ui doesn't track
ElementOverrideSet Update Override this frame's animated and eased values
ElementRasterSet Update Repaint an element-owned texture from this frame's final state
PickRefineSet PreUpdate Refine a node's picking hit into a sub-element hit
MeasureStampSet PostUpdate Re-stamp an intrinsic ContentSize measure before layout

The sets other than ReactApplySet (crate root) and AnimationSet (bevy_react::animations) live in bevy_react::ext.

Typing

The TypeScript exporter generates a props interface per registered element (<cube> gets BevyCubeProps): the shared prop groups, style for an element with a box, the typed attributes, the event handlers, and children. Your bevy.ts adds them to JSX by augmenting the package's BevyIntrinsicElements interface. Type names in a codec's TS literal that no ts-rs type declares are imported from the bevy-react package, so use its exported types there (Animatable<number>, Color).

Register the element on both paths: in the running app and in the --export-bindings exporter. A shared register_bindings(app) function called from both keeps them in step. Then regenerate bevy.ts. See TypeScript codegen.

A kind no plugin registered mounts as a plain <node> so its children still attach, and its props are dropped. Only the kinds of bevy-react's own optional features (svg, canvas, portal, …) report a featureMissing warning; an unregistered app element mounts silently. A current bevy.ts catches the mistake at type-check time.

Beyond UI

A node-less, detached element is how bevy-react drives entities that aren't UI at all — meshes, lights, audio emitters. Such an element gets the React-facing half of the system:

  • Mounting, updating and unmounting with React, keyed lists included. It despawns when its React ancestor unmounts.
  • Attributes decoded from props, with writers re-run only for what changed.
  • { animated } attributes, driven every frame in Bevy (see Animated attributes).
  • Element events with typed payloads.
  • name for lookups from your systems, when the element takes the identity props (see Named nodes).
  • onClick through any picking backend that hits the entity, and onPointerEnter/onPointerLeave once you drive its Interaction (see Pointer events).
  • Generated JSX typing.

Everything built on bevy_ui stays with UI nodes:

  • No style, so no layout, no hover, press or focus styles, no style transitions, and no transforms, opacity, filters or layers. The entity is positioned and shaped by its attributes and your systems.
  • No picking by default: add a backend (MeshPickingPlugin for meshes) and sync Interaction yourself.
  • No drag events (onPointerDown/Move/Up).
  • No shared elements: those need a transition style.
  • No easing of static attribute changes. Use { animated } bindings, or build a channel on the core's transition::Channel (the <svg> shapes' transition prop is made that way).
  • The Bevy parent is yours to choose; the core never attaches a detached entity in the hierarchy.

Limits

  • At most 64 attributes, 64 writers and 64 events per element.
  • Registering the same name twice with a different declaration panics.
  • Attributes take static values or { animated } bindings only; they have no hover, press or focus variants (those are style).
  • No per-frame callback: React re-renders or a binding drive changes. Continuous behavior belongs in your own systems.
  • A node-less element has no style, layout box or composited layer.

See the element reference for the built-in elements, which are declared the same way.