Elements and Styling
The elements a cart draws with, how styling works, and the traps in it
A cart's screen is React, but the things it renders are not HTML elements and the styles are not CSS. They are close enough to feel familiar and different enough to catch you out, so this page is the short version of both.
import { View, Text, Button } from "oj"
<View style={{ flexDirection: "row", padding: 12, backgroundColor: "#1b2130" }}>
<Text style={{ color: "white", fontSize: 18 }}>Score: 12</Text>
<Button text="Again" onClick={restart} />
</View>The elements
View |
The box everything else is built from. There is no div |
Text |
A run of text. What you want most of the time |
Label |
A form label. Text for display, Label next to a control |
Button |
Takes onClick, and its label as text or as children |
TextField |
Single or multi-line text entry |
Toggle |
A checkbox |
Slider |
A numeric range |
ScrollView |
A box whose content can be longer than it is |
Image |
A picture. See Shipping Files |
ListView, TreeView |
Virtualised lists, for thousands of rows |
FrostedGlass |
A blurred backdrop |
That is the whole set. There is no span, no p, no ul: a View with the right styles is all of those.
Raw text inside an element works, so <View>hello</View> renders, but reach for Text when you want to style it.
Styling
Styles are objects, applied inline, and every element takes a style prop.
<View style={{ width: 200, height: 120, borderRadius: 8, backgroundColor: "#2a3240" }} />Numbers are pixels. Strings carry their own unit, so "50%" is a percentage. Colours are strings: "#ff5500", "rgb(255, 85, 0)", "rgba(255, 85, 0, 0.5)".
Layout is flexbox and only flexbox. There is no display: block, no floats, no grid. Every element is a flex container, and a View with no other styling stacks its children vertically.
style={{
flexDirection: "row", // or "column", the default
justifyContent: "center", // along the direction
alignItems: "center", // across it
flexGrow: 1, // take the spare room
flexShrink: 0, // and do not give up your own
position: "absolute", // with left / top / right / bottom
}}Five shorthands work: padding, margin, borderWidth, borderColor and borderRadius. They expand to their four sides for you. Anything else has to be written out per property, so there is no background or font shorthand.
For anything beyond a handful of styles, CSS Modules and Tailwind both work and are worth the setup on a cart with more than one screen.
One Tailwind trap worth knowing: the width and height utilities come from the spacing and percentage scales (w-96, max-w-1/2) and not the named size scale, so max-w-md and friends compile to nothing at all and your element keeps its full width. Write the number, or set maxWidth as a style.
Events
<Button text="Jump" onClick={() => jump()} />
<Toggle value={muted} onChange={(e) => setMuted(e.value)} />
<Slider value={vol} min={0} max={100} onChange={(e) => setVol(e.value)} />
<View onPointerDown={(e) => console.log(e.x, e.y)} />Events bubble. e.stopPropagation() stops them; e.preventDefault() stops the native control underneath (a ScrollView scrolling, for instance) from acting.
For anything a cart reads every frame, use input inside useFrame rather than an event handler. Events are for the UI around the cart: buttons, menus, sliders.
Three traps
Each of these has cost a shipped cart a day. None of them produces an error, which is why they are worth reading before you meet them.
A change event carries value, not newValue
onChange={(e) => setVolume(e.value)} // right
onChange={(e) => setVolume(e.newValue)} // undefined, silentlyA handler typed loosely accepts the wrong name and hands you undefined, so the symptom is a control that does nothing. Every slider in one published cart was dead because of this.
There is no e.target.value either. e.target exists but it is an integer handle, not an element.
Pointer x and y are window pixels; use localX and localY for your own box
e.x and e.y are measured from the top left of the window, the same numbers as input.mouse.position and useStage(), not from the element your handler is on. For a position inside your own element, read e.localX and e.localY, which are relative to the element the handler sits on:
<View style={{ width: 200, height: 200 }}
onPointerDown={(e) => {
const nx = e.localX / 200 // 0 at the left edge, 1 at the right
}} />They are computed from the element's bounds the first time a handler reads them, so a handler that never asks pays nothing. On a bubbling event they follow currentTarget: a parent's handler sees coordinates relative to the parent.
flexShrink defaults to 1
Children of a fixed-height box give up their own height to fit rather than overflowing it. Add rows to a box with a set height and every row gets shorter; add enough and one row's text is drawn across the row above.
It reads as a rendering bug rather than a layout one, which is what makes it expensive: the data is right and only a screenshot shows it.
<View style={{ height: 124 }}>
{rows.slice(0, 4).map((row) => (
<View key={row.id} style={{ flexShrink: 0, flexDirection: "row" }}>...</View>
))}
</View>Give rows flexShrink: 0 and cap how many you show. A panel that lists four names and says "and 6 more" is honest; one that draws ten on top of each other is not.
Refs, and keeping React out of the frame loop
React is for the parts of the screen that change a few times a second. Anything moving every frame should be written straight to the element, through a ref:
const ref = useRef<VisualElement>(null)
useFrame((dt) => {
x.current += 200 * dt
if (ref.current) ref.current.style.left = x.current
})
return <View ref={ref} style={{ position: "absolute", width: 32, height: 32 }} />ref.current is the underlying element, and null until the first render has made it, which is what the if is for. VisualElement comes from "oj". Assigning to ref.current.style.left moves it without a render, where setState would rebuild the tree sixty times a second.
Refs are also how you read an element's measured size and position, through worldBound (in window pixels) and layout (relative to its parent). Both are only meaningful after the first layout pass, so read them in an effect or a frame callback, never during render.
Going deeper
These pages describe the same elements and styles from the Unity side, in full:
| Components | Every element, prop by prop |
| Styling | The complete style property list, USS, transitions and transforms |
| Events | Every event type and what it carries |
| Refs | Reaching an element directly |
They assume a Unity project around them, so skip the setup and take the reference.