Project Structure & Build
This page covers everything around a superui UI that isn’t the components
themselves: where files live, how to get editor support for .tsx, the three
ways a UI gets built, and how hot reload works.
The UI directory
Each UI is a directory somewhere under your Bevy assets/ folder, laid out like a
tiny web page. The path is entirely your choice — you pass it to
from_asset_dir — but the examples group UIs under assets/ui/<name>/, and this
guide follows that convention:
assets/ui/counter/
├── index.html ← entry point / manifest
├── style.css ← styles
└── app.tsx ← components + render() call
index.html is the manifest
index.html is the single entry superui loads. It links the other files exactly
like a browser would:
<html>
<head>
<link rel="stylesheet" href="style.css">
<script type="module" src="app.tsx"></script>
</head>
<body>
<div id="root"></div>
</body>
</html>
<link rel="stylesheet">pulls in the CSS.<script type="module" src="…">names your component module. Point it atapp.tsxfor a supersolid UI.- The
<body>provides the mount target (#root) that yourrender()call looks up.
Because index.html references everything else, mounting only needs that one
path:
commands.spawn(SuperUiRoot::from_asset_dir("ui/counter", &assets));
from_asset_dir("ui/counter", …) loads assets/ui/counter/index.html and
bundles a full-viewport root Node so percentage-based and inset children
resolve against the whole window. If you need a custom root node, use
SuperUiRoot::from_asset_dir_with(dir, node, &assets) instead.
Editor support for .tsx
Your .tsx is real TypeScript as far as the editor is concerned, so you get
autocomplete, hover docs, and type-checking — but only once the supersolid
types are resolvable. superui projects them into your project with a CLI:
cargo superui install
This writes a gitignored superui_modules/ directory containing the ambient type
declarations and a tsconfig.json that maps the bare supersolid import to
them:
{
"compilerOptions": {
"jsx": "preserve",
"moduleResolution": "bundler",
"lib": ["ESNext", "DOM"],
"paths": {
"supersolid": ["./superui_modules/supersolid/index.d.ts"]
}
},
"include": ["superui_modules/**/*.d.ts", "assets/**/*.tsx"]
}
Nothing here affects the build or runtime — superui’s Rust transpiler is the real
consumer of your .tsx. The tsconfig exists purely so your editor understands the
code (jsx: "preserve" leaves JSX untouched; there is no React runtime).
superui_modules/ is regenerated by the command, so keep it out of version
control.
Build modes
The same .tsx runs three ways, and which one you get depends on how you build:
| Command | Source of truth | Hot reload |
|---|---|---|
cargo run --features hmr | live .tsx, transpiled on load | yes |
cargo run | pre-built .superui/build/*.js | no |
cargo build --target wasm32-unknown-unknown | pre-built .superui/build/*.js | no |
Live .tsx with hot reload
Enable an hmr feature in your crate:
[features]
hmr = ["superui/hmr", "bevy/file_watcher"]
Then cargo run --features hmr transpiles .tsx as it loads and watches the
files. Editing a component rebuilds that UI in place. See
Hot reload below.
Pre-transpiled JS (release native and web)
Without hmr, the transpiler is not in the running binary — so it can’t live-load
.tsx, and on the web the transpiler (which uses native-only tooling) must not be
compiled into the wasm binary at all. For these builds, transpile your .tsx
ahead of time in a build script. Add a build.rs:
//! Pre-transpile the UI's `.tsx` to `.superui/build/*.js`. Build scripts run on
//! the host, so the transpiler never enters the wasm binary.
fn main() {
supersolid::build::transpile_dir("assets/ui/counter");
}
with the matching build dependency:
[build-dependencies]
supersolid = "0.3"
This emits assets/ui/counter/.superui/build/app.js, which a plain cargo run
or a wasm build loads instead of the .tsx. Keep the generated output out of
version control:
**/.superui/
superui_modules/
Building for the web
A wasm build additionally needs the JS getrandom backend (the embedded JS
engine pulls in randomness) and a WebGL2 renderer:
[target.'cfg(target_arch = "wasm32")'.dependencies]
getrandom = { version = "0.3", features = ["wasm_js"] }
bevy = { version = "0.19", features = ["webgl2"] }
To bind Bevy’s window to a canvas on the host page, set the canvas selector on the
primary window when targeting wasm — see the counter example’s main.rs for the
pattern.
Hot reload
superui’s hot reload isn’t a bespoke mechanism — it rides on Bevy’s standard
asset hot-reloading. A UI’s index.html, .tsx, and .css are ordinary Bevy
assets, so when their files change on disk, Bevy reloads them exactly like it would
any other changed asset, and superui reacts by rebuilding the affected UI.
That means the watching itself is set up the usual Bevy way: through Bevy’s asset
file watcher, which is enabled by the file_watcher cargo feature. If you’ve
turned on asset hot-reloading in a Bevy project before, this is the same knob — and
the hmr feature in Live .tsx with hot reload turns
bevy/file_watcher on for you, so cargo run --features hmr is watching out of the
box. (See Bevy’s hot-reloading
docs for the general
mechanism.)
What superui adds on top is state preservation. When the hmr feature is on
and the asset server is watching, saving a .tsx (or .css) re-runs the UI on
the same engine — and it does so state-preservingly. Rather than resetting everything, render() rehydrates each
component’s signals (matched by module, component instance, and creation order),
rebuilds the DOM fresh, and pours the old values back in. A running counter keeps
its count; an open menu keeps which tab was selected.
Some edits necessarily reset state:
- Adding or removing a signal in a component changes its signal “shape”, so that instance starts from its fresh defaults.
- List rows preserve state by identity:
<For>rows by item identity,<Index>rows by position. See Control Flow.
Hot reload is a development convenience — with the feature off, or with no file
watcher, render() takes its normal path and none of this machinery runs.