Styling#
OneJS supports inline styles, USS (Unity Style Sheets), CSS Modules, and Tailwind (built-in, no external dependencies).
Inline Styles#
Apply styles directly to elements:
<View
style={{
width: 200,
height: 100,
backgroundColor: "#1a1a1a",
padding: 20,
borderRadius: 8,
}}
>
<Label text="Styled content" style={{ color: "#ffffff" }} />
</View>Value Types#
Numbers: Interpreted as pixels:
style={{ width: 100, padding: 20 }}Strings: For percentages, keywords, or complex values:
style={{ width: "50%", height: "auto" }}Colors: Hex, rgb, rgba, or named colors:
style={{
backgroundColor: "#ff0000",
color: "rgb(255, 255, 255)",
borderColor: "rgba(0, 0, 0, 0.5)",
}}Layout Properties#
Dimensions#
style={{
width: 200,
height: 100,
minWidth: 50,
maxWidth: 400,
minHeight: 50,
maxHeight: 300,
}}Flexbox#
style={{
flexDirection: "row", // "row" | "column" | "row-reverse" | "column-reverse"
flexWrap: "wrap", // "nowrap" | "wrap" | "wrap-reverse"
justifyContent: "center", // "flex-start" | "center" | "flex-end" | "space-between" | "space-around"
alignItems: "center", // "flex-start" | "center" | "flex-end" | "stretch"
alignContent: "center",
flexGrow: 1,
flexShrink: 0,
flexBasis: "auto",
}}A list in a fixed-height box compresses instead of overflowing.flexShrinkdefaults to1, so children give up height to fit rather than pushing the box open or spilling out of it. Add a row to a box with a setheightand 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, the tests pass, and only a screenshot shows it.
Give rows
flexShrink: 0so they keep their height, then decide how many the box can hold. A panel that shows four names and says "and 6 more" is honest; one that shows ten names on top of each other is not.> <View style={{ height: 124 }}> > {entries.slice(0, 4).map((e) => ( > <View key={e.id} style={{ flexShrink: 0, flexDirection: "row" }}>...</View> > ))} > </View> >
Spacing#
style={{
// Shorthand (applies to all sides)
margin: 10,
padding: 20,
// Or individual sides
marginTop: 10,
marginRight: 10,
marginBottom: 10,
marginLeft: 10,
paddingTop: 20,
paddingRight: 20,
paddingBottom: 20,
paddingLeft: 20,
}}Shorthands like margin, padding, borderWidth, borderColor, and borderRadius are automatically expanded to individual properties.
Positioning#
style={{
position: "absolute", // "relative" | "absolute"
top: 10,
right: 10,
bottom: 10,
left: 10,
}}Coming from the Web#
USS is close enough to CSS that most of what you know transfers, but a few defaults differ. They are deliberate: UI Toolkit is a retained-mode UI system, not a document renderer, and these are the places where that shows.
| Web | OneJS | |
|---|---|---|
display | block, inline, flex, grid | Always flex. There is no other layout mode. |
flex-direction | row on a flex container | column |
position | static | relative |
| Box model | content-box | Always border-box |
| A flex item's minimum size | Its content | 0 |
position: absolute with no offsets | Where it would sit in the flow | The parent's top left |
Every element is a flex container. UI Toolkit has no block layout, so display cannot turn flexbox on or off. Children stack vertically by default, which is what a plain <div> of <div>s does on the web, but the mechanism is flexbox the whole way down. For a horizontal row, set the direction explicitly:
<View style={{ flexDirection: "row" }}>...</View>top, right, bottom and left apply without positioning. The default is relative, not static, so an offset takes effect on any element. On the web the same offset on an unpositioned element is silently ignored.
A flex item can shrink below its content. On the web a flex item's minimum size defaults to its content, so a long word in a row too narrow for it overflows the row. Here the minimum is 0, so the item squeezes to fit and its text no longer has the room it needs. Give an item that must keep its size flexShrink: 0, or a minWidth.
An absolute element with no offsets goes to its parent's top left, not to where it would have sat among its siblings. Give it top and left, or right and bottom.
Padding and borders are inside the box. width: 100 with padding: 10 leaves 80 for content. This is box-sizing: border-box, the value Tailwind sets globally, so ports from a Tailwind codebase see no difference here.
Controls arrive styled#
<Button>, <TextField>, <Toggle> and <Slider> are Unity controls, and OneJS ships a dark theme for them. Unlike a browser's near-bare <button>, they come with padding, a border and a background already set. A <ScrollView> is transparent, but its scrollbars are themed too.
You still override them normally. Inline styles always win, and your class rules beat the theme at equal specificity because the theme is applied first. The exception is state: the theme styles hover and focus through two-class selectors like .unity-text-field:focus .unity-text-field__input, so a single class of your own will not override those. Match the selector shape to win.
Some controls are also several elements. A <TextField> is an outer field wrapping an inner input, and the border and background live on the inner one. className and style land on the outer element, so TextField takes inputClassName and inputStyle for the inner input directly:
<TextField
inputClassName="bg-gray-900 border-none rounded-lg"
inputStyle={{ paddingLeft: 12 }}
/>USS can reach it too, through Unity's own class on the inner element (.unity-text-field__input), which is also how Toggle's checkmark (.unity-toggle__checkmark) and Slider's dragger (.unity-base-slider__dragger) are styled. If you would rather not style Unity's internals at all, onejs-ui ships components that have already done it.
Visual Properties#
Backgrounds#
style={{
backgroundColor: "#2a2a2a",
backgroundImage: texture, // Texture2D or RenderTexture
}}Background images accept a Unity Texture2D, Sprite, VectorImage (what loadImage returns for an SVG), RenderTexture, or a GPU compute RenderTexture:
import { loadImage } from "onejs-unity/assets"
function Card() {
const [texture, setTexture] = useState(null)
useEffect(() => {
const tex = loadImage("images/card-bg.png")
setTexture(tex)
}, [])
return (
<View style={{ backgroundImage: texture, width: 200, height: 150 }}>
<Label text="Card Content" />
</View>
)
}Borders#
style={{
borderWidth: 1,
borderColor: "#333333",
borderRadius: 8,
// Or individual sides
borderTopWidth: 2,
borderLeftColor: "#ff0000",
borderTopLeftRadius: 4,
}}Display#
style={{
display: "flex", // "flex" | "none"
visibility: "visible", // "visible" | "hidden"
overflow: "hidden", // "visible" | "hidden"
opacity: 0.8,
}}Filters#
filter (Unity 6.3+) and backdrop-filter (Unity 6.6+) take USS filter functions: blur, grayscale, sepia, invert, opacity, contrast, hue-rotate, tint, and drop-shadow from 6.6. filter applies to the element itself, backdrop-filter to what is behind it.
style={{
filter: "blur(2px) grayscale(50%)",
backdropFilter: "blur(8px)",
}}The same values work in USS files and CSS Modules (filter: hue-rotate(90deg);). Tailwind has no filter utilities. A function the running Unity does not have is reported with a warning and left out.
These are Unity's own filters. FrostedGlass is OneJS's component for blurring the 3D scene behind a panel, and works from Unity 6.3.
Typography#
<Label
text="Styled text"
style={{
fontSize: 16,
color: "#ffffff",
unityFontStyleAndWeight: "bold", // "normal" | "bold" | "italic" | "bold-and-italic"
unityTextAlign: "middle-center", // Text alignment
whiteSpace: "normal", // "normal" | "nowrap"
letterSpacing: 2,
wordSpacing: 4,
}}
/>Custom Fonts#
Use loadFontDefinition from onejs-unity/assets to load a font and apply it via unityFontDefinition:
import { loadFontDefinition } from "onejs-unity/assets"
function StyledText() {
const [fontDef, setFontDef] = useState(null)
useEffect(() => {
const def = loadFontDefinition("fonts/Consolas-Regular.ttf")
setFontDef(def)
}, [])
return (
<Label style={{ unityFontDefinition: fontDef, fontSize: 18 }}>
Custom font text
</Label>
)
}Text Overflow#
style={{
textOverflow: "ellipsis", // "clip" | "ellipsis"
unityTextOverflowPosition: "end", // "end" | "start" | "middle"
whiteSpace: "nowrap", // Required for ellipsis to work
}}Text Outline#
style={{
unityTextOutlineColor: "#000000",
unityTextOutlineWidth: 1,
}}All Typography Properties#
| Property | Type | Values |
|---|---|---|
fontSize | Length | 16, "16px" |
color | Color | "#fff", "rgb(255,0,0)" |
unityFont | object | C# Font object |
unityFontDefinition | object | C# FontDefinition from loadFontDefinition() |
unityFontStyleAndWeight | enum | "normal", "bold", "italic", "bold-and-italic" |
unityTextAlign | enum | "upper-left", "middle-center", etc. |
whiteSpace | enum | "normal", "nowrap" |
letterSpacing | Length | 2, "2px" (applied as hundredths of an em: 2 is 2% of the font size) |
wordSpacing | Length | 4, "4px" |
unityParagraphSpacing | Length | 10, "10px" |
unityTextOutlineColor | Color | "#000" |
unityTextOutlineWidth | number | 1 |
textOverflow | enum | "clip", "ellipsis" |
unityTextOverflowPosition | enum | "end", "start", "middle" |
Background Image Tint#
Tint the background image with a color:
style={{
backgroundImage: texture,
unityBackgroundImageTintColor: "rgba(255, 0, 0, 0.5)",
}}9-Slice#
Control how background images are sliced for scalable UI:
style={{
backgroundImage: texture,
unitySliceTop: 12,
unitySliceRight: 12,
unitySliceBottom: 12,
unitySliceLeft: 12,
unitySliceScale: 1,
}}Transform#
Apply rotation, scale, and translation transforms using friendly shorthand values:
style={{
translate: [30, 0],
rotate: 45,
scale: 1.5,
transformOrigin: ["50%", "50%"],
}}translate#
Accepts [x, y] where values are numbers (px) or strings ("50%", "10px"):
style={{ translate: [30, 0] }} // 30px right
style={{ translate: [0, 20] }} // 20px down
style={{ translate: ["50%", 0] }} // 50% of own width
style={{ translate: ["50%", "50%"] }}rotate#
Accepts a number (degrees) or string with unit:
style={{ rotate: 45 }} // 45 degrees
style={{ rotate: "0.5turn" }} // half turn (180deg)
style={{ rotate: "45deg" }}
style={{ rotate: "1.57rad" }}scale#
Accepts a number (uniform) or [x, y]:
style={{ scale: 1.5 }} // 150% on both axes
style={{ scale: 0.5 }} // 50% on both axes
style={{ scale: [2, 0.5] }} // stretch horizontal, squash verticaltransformOrigin#
Accepts [x, y] (same value types as translate):
style={{ transformOrigin: ["0%", "0%"], rotate: 30 }} // top-left pivot
style={{ transformOrigin: ["50%", "50%"], rotate: 30 }} // center pivot
style={{ transformOrigin: ["100%", "100%"], rotate: 30 }} // bottom-right pivotC# Struct Pass-through#
You can also pass C# structs directly. They are auto-wrapped in Style* types:
const UIE = CS.UnityEngine.UIElements
style={{
translate: new UIE.Translate(
new UIE.Length(30, UIE.LengthUnit.Pixel),
new UIE.Length(0, UIE.LengthUnit.Pixel)
),
rotate: new UIE.Rotate(UIE.Angle.Degrees(45)),
}}Transitions#
Animate style changes with transitions. These accept C# StyleList values:
style={{
transitionProperty: /* StyleList of property names */,
transitionDuration: /* StyleList of TimeValue */,
transitionDelay: /* StyleList of TimeValue */,
transitionTimingFunction: /* StyleList of EasingFunction */,
}}For most use cases, transitions are easier to define in USS:
.animated {
transition-property: background-color, opacity;
transition-duration: 0.3s;
transition-timing-function: ease-in-out;
}Transitioning on Mount#
A transition runs between two resolved styles, so the element has to be styled once without the change first. A class that is already on the element when it is first laid out is simply its starting style, and the element lands on the end state at once. A class set from a useEffect on mount usually arrives that early, and a timer is no fix, since nothing promises the layout has happened when it fires. Start the change from the element's first onGeometryChanged instead:
function Map() {
const [laidOut, setLaidOut] = useState(false)
return (
<View
className={laidOut ? "map map--far" : "map"}
onGeometryChanged={() => setLaidOut(true)}
/>
)
}Other Properties#
| Property | Type | Values |
|---|---|---|
unityOverflowClipBox | enum | "padding-box", "content-box" |
cursor | object | C# Cursor struct |
USS Stylesheets#
For reusable styles, use USS files:
/* styles/main.uss */
.card {
background-color: #2a2a2a;
padding: 20px;
border-radius: 8px;
margin-bottom: 10px;
}
.card-title {
font-size: 18px;
color: #ffffff;
margin-bottom: 10px;
}
.button-primary {
background-color: #0066cc;
color: #ffffff;
padding: 10px 20px;
border-radius: 4px;
}
.button-primary:hover {
background-color: #0077ee;
}Import the file as a string and compile it at startup. This embeds the USS in the JS bundle, so it works both in the Editor and in builds (including WebGL):
import mainUss from "./styles/main.uss"
compileStyleSheet(mainUss, "main.uss")
// Use classes
<View className="card">
<Label text="Card Title" className="card-title" />
<Button text="Action" className="button-primary" />
</View>This relies on esbuild's text loader, which new projects get by default. If your project was created before it was added, put loader: { ".uss": "text" } in esbuild.config.mjs.
Note: CSS Modules and Tailwind also embed styles in the bundle, so they work in builds automatically. Avoid loadStyleSheet() for app styles: it reads from the filesystem at runtime, and working-directory files are not shipped in builds.
StyleSheet API#
OneJS provides functions for loading and managing stylesheets at runtime.
From OneJS 3.9.5 the same four functions are also under the onejs namespace, as onejs.styles.load, compile, remove and clear. onejs.styles.compile(uss, "my-styles") is the same call as compileStyleSheet(uss, "my-styles"), and the globals keep working.
loadStyleSheet#
Load a USS file from the working directory:
loadStyleSheet("styles/main.uss") // Returns true if successfulThe working directory is your ~/ folder in the Editor and Application.persistentDataPath in a build. Files in ~/ are not shipped, so in a build it only finds files the game wrote there itself, such as a theme a player saved. To ship styles, import the .uss file and use compileStyleSheet() instead (see USS Stylesheets above).
compileStyleSheet#
Compile a USS string and apply it to the root element. If a stylesheet with the same name already exists, it will be replaced automatically (deduplication):
const uss = `
.my-class {
background-color: #333;
padding: 10px;
}
`
compileStyleSheet(uss, "my-styles") // Name used for deduplicationThis is how CSS Modules and Tailwind work internally: they embed USS in the JavaScript bundle and call compileStyleSheet() at runtime.
removeStyleSheet#
Remove a specific stylesheet by name:
removeStyleSheet("my-styles") // Returns true if found and removedclearStyleSheets#
Remove all JS-loaded stylesheets. Does not affect Unity asset-based stylesheets:
const count = clearStyleSheets() // Returns number of stylesheets removed
console.log(`Removed ${count} stylesheets`)Hot Reload Behavior#
Stylesheets are automatically deduplicated by name. When you hot reload your app, re-importing CSS Modules or Tailwind won't accumulate duplicate styles. The existing stylesheet is replaced.
USS Variables#
Define reusable values with custom properties:
:root {
--accent: #FF6600;
--spacing: 20px;
--bg-dark: #333333;
}
.card {
background-color: var(--bg-dark);
padding: var(--spacing);
}
.card-title {
color: var(--accent);
}Use var() with a fallback value for when the variable is undefined:
.text {
color: var(--undefined-var, #00CCFF);
}Variables work in all styling methods: plain USS files, CSS Modules, and inline compileStyleSheet() calls.
Limitations: USS variables are simpler than CSS variables. var() cannot be nested inside other functions like rgb(), and mathematical operations are not supported.
Pseudo-classes#
USS supports pseudo-classes for interactive states:
.button:hover {
background-color: #333333;
}
.button:active {
background-color: #444444;
}
.button:focus {
border-color: #0066cc;
}
.input:focus {
border-color: #0066cc;
border-width: 2px;
}Combining Styles#
Use both className and inline styles:
<View
className="card"
style={{ width: 300 }} // Inline overrides USS
>
<Label text="Content" />
</View>Inline styles take precedence over USS classes.
Dynamic Styles#
Compute styles based on state:
function Button({ active, children }) {
return (
<View
style={{
backgroundColor: active ? "#0066cc" : "#333333",
padding: 10,
borderRadius: 4,
}}
>
{children}
</View>
)
}Style Objects#
Extract and reuse style objects:
const styles = {
container: {
padding: 20,
backgroundColor: "#1a1a1a",
},
title: {
fontSize: 24,
color: "#ffffff",
marginBottom: 20,
},
button: {
padding: 10,
backgroundColor: "#0066cc",
borderRadius: 4,
},
}
function Screen() {
return (
<View style={styles.container}>
<Label text="Title" style={styles.title} />
<Button text="Click" style={styles.button} />
</View>
)
}Common Patterns#
Centering#
// Center children
<View style={{
justifyContent: "center",
alignItems: "center",
height: "100%",
}}>
<Label text="Centered" />
</View>Full Screen#
<View style={{
position: "absolute",
top: 0,
right: 0,
bottom: 0,
left: 0,
}}>
{/* Content fills screen */}
</View>Responsive Width#
<View style={{
width: "100%",
maxWidth: 800,
marginLeft: "auto",
marginRight: "auto",
}}>
{/* Centered, max 800px */}
</View>Debugging Styles#
When USS selectors aren't applying as expected, use the built-in debugging utilities to inspect the visual tree.
Dump Visual Tree#
Use __dumpUI() to output the element hierarchy with USS classes:
// Dump entire UI from root
console.log(__dumpUI())
// Dump specific element with depth limit
console.log(__dumpUI(myElement, 5))
// Include computed styles
console.log(__dumpUI(myElement, 5, true))Output shows element types, names, and USS classes:
<VisualElement name="root" class="unity-ui-document">
<VisualElement class="container">
<Button class="unity-button btn-primary">
</Button>
</VisualElement>
</VisualElement>Find Elements by Class#
Use __findByClass() to locate elements with a specific USS class:
// Find all elements with class "btn-primary"
console.log(__findByClass("btn-primary"))Returns element info including pseudo-states and computed styles:
Found 2 elements with class 'btn-primary':
Type: Button
Name: submit-btn
Classes: [unity-button, btn-primary]
Pseudo States: [:hover]
Styles:
backgroundColor: rgba(0.24, 0.47, 0.99, 1.00)Find Elements by Type#
Use __findByType() to find all elements of a specific type:
// Find all TextField elements
console.log(__findByType("TextField"))
// Find all Buttons
console.log(__findByType("Button"))Common Debugging Scenarios#
Selector not matching: Dump the element to see its actual USS classes and verify your selector matches.
Style not applying: Check if a higher-specificity rule is overriding yours. Inline styles always win.
Pseudo-class issues: Use __findByClass() to see which pseudo-states (:hover, :focus, etc.) are active.
Inherited styles: Remember that UI Toolkit uses visual tree inheritance, not DOM inheritance. Dump the parent chain to trace style sources.