Elements and Styling

The elements a game draws with, how styling works, and the traps in it

A game'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 text and onClick
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 game with more than one screen.

Events

<Button text="Jump" onClick={() => jump()} />
<Toggle value={muted} onChange={(e) => setMuted(e.value)} />
<Slider value={vol} lowValue={0} highValue={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 game reads every frame, use input inside useFrame rather than an event handler. Events are for the UI around the game: buttons, menus, sliders.

Three traps

Each of these has cost a shipped game 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, silently

A 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 game 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 events have no localX or localY

e.x and e.y are measured from the top left of the panel, not from the element your handler is on. For a position inside your own element, subtract its bounds:

onPointerDown={(e) => {
    const box = ref.current.worldBound
    const localX = e.x - box.x
    const localY = e.y - box.y
}}

A game shipped with every card unclickable because it reached for e.localX, got undefined, and hit-tested against NaN.

Note also that these are panel pixels, while input.mouse.position is in stage units. Do not mix the two: on a letterboxed stage they differ by the size of the bars.

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 }}>
    {scores.slice(0, 4).map((s) => (
        <View key={s.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(null)

useFrame((dt) => {
    x.current += 200 * dt
    ref.current.style.left = x.current
})

return <View ref={ref} style={{ position: "absolute", width: 32, height: 32 }} />

ref.current is the underlying element. 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 panel space) 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.