Theming#

A theme in onejs-ui is a plain object of design tokens (ThemeTokens). applyTheme(tokens) emits it as --ojs-* USS custom properties on the render root, and every component references those tokens through var(--ojs-*) in its .module.uss file.

Because Unity resolves var() against the cascade at apply time, swapping themes is one small variables-sheet recompile: no React re-render, no per-element style re-marshalling.

ThemeProvider#

Wrap your app once. <ThemeProvider> applies the theme and exposes useTheme():

import { ThemeProvider, useTheme, darkTheme, lightTheme } from "onejs-ui"

function ThemeToggle() {
    const { tokens, setTheme } = useTheme()
    const isDark = tokens === darkTheme
    return (
        <Button onClick={() => setTheme(isDark ? lightTheme : darkTheme)}>
            {isDark ? "Light" : "Dark"} mode
        </Button>
    )
}

render(
    <ThemeProvider theme={darkTheme}>
        <App />
    </ThemeProvider>,
    __root,
)

useTheme() returns { tokens, setTheme }. Calling setTheme(next) re-applies the variables sheet; the UI recolors immediately.

Custom themes#

Start from a default and override only what you need. ThemeTokens is a flat object, so a spread is enough:

import { darkTheme, type ThemeTokens } from "onejs-ui"

const brand: ThemeTokens = {
    ...darkTheme,
    primary: "#7c5cff",
    primaryHover: "#6a47f0",
    ring: "#cdb8ff",
}

<ThemeProvider theme={brand}>…</ThemeProvider>

You can also build a theme from scratch by providing every token.

Themes by name#

Themes can be registered by name and then referenced as a string anywhere a theme is accepted (<ThemeProvider theme="brand">, setTheme("brand")). This is handy for theme pickers and for premade themes that register themselves on import.

import { registerTheme, ThemeProvider, useTheme } from "onejs-ui"

registerTheme("brand", brand) // a premade theme does this for you on import

function ThemeMenu() {
    const { setTheme } = useTheme()
    return <Button onClick={() => setTheme("brand")}>Brand</Button>
}

<ThemeProvider theme="brand">…</ThemeProvider>

"dark" and "light" are always registered; getRegisteredThemes() lists the names (for building a switcher). A name must be registered before use: importing the theme module is what registers it.

Token reference#

GroupTokens
Surfacesbg, surface, surfaceRaised, overlay, overlayHover, overlayActive (optional)
Foregroundfg, fgMuted, fgSubtle
Lines & focusborder, ring, focusWidth (optional, default 2px)
Accentprimary, primaryHover, primaryActive (optional), onPrimary
Statusdanger, dangerHover (optional), dangerActive (optional), onDanger, success, warning, info
RadiusradiusSm, radiusMd, radiusLg, radiusFull
Spacingspace1space6
Type scalefontSizeSm, fontSizeMd, fontSizeLg, fontSizeXl
Font (optional)font
MotionmotionFast, motionBase, motionSlow, ease
Each token maps to a kebab-cased custom property: surfaceRaised--ojs-surface-raised, space4--ojs-space-4, ring--ojs-ring.

The pressed-state tokens are optional with sensible fallbacks: primaryActive falls back to primaryHover, overlayActive to overlayHover, and dangerActive to dangerHover (which falls back to danger). Set them when you want click-hold to read as a distinct step past hover, as the built-in themes do.

Optional image / sprite slots (frames, control skins) are covered in Component skins.

Using tokens in your own styles#

Reference tokens from your own .module.uss files so your components recolor with the theme:

.panel {
    background-color: var(--ojs-surface-raised);
    border-color: var(--ojs-border);
    border-radius: var(--ojs-radius-md);
    padding: var(--ojs-space-4);
    color: var(--ojs-fg);
}
Inline style= cannot read USS variables, so it's never used for themeable properties. Only USS classes can resolve var(--ojs-*). Use className (and .module.uss) for anything that should follow the theme; reserve inline style for one-off layout values.

Fonts#

Set the optional font token to theme typography. It's applied on the render root and inherited by every component (UI Toolkit's font is an inherited property), so one token restyles all text:

const themed: ThemeTokens = {
    ...darkTheme,
    font: `resource("MyTheme/MyFont")`, // a Resources asset (build-safe); see the note below
}

Omit it and the panel's default font is used. As with image tokens, prefer resource() over url() so it resolves in player builds (see Component skins for why).

Component skins#

Optional slots let a theme dress components in sprites: 9-slice frames, control art. All default to no-op, so the flat token look is unchanged unless you set them. This is how the premade pixel / skinned themes work.

Three kinds of slot:

  • Image (...Image, plus ...ImageChecked / ...ImageHover / ...ImageActive for stateful controls). A USS image value. Prefer resource("path") over url("path"): url() is editor-only in OneJS, resource() (an asset under a Resources/ folder) works in player builds. Import the texture as Point filter / no mipmaps for crisp pixels.
  • Slice (...Slice, ...SliceScale, default 1px). Unitless px inset for 9-slicing a frame that stretches.
  • Shape (...BorderWidth, ...Radius). Set 0 to square / unframe a native control so the sprite is the whole look.
ComponentSlots
CardcardImage, cardSlice, cardSliceScale
ButtonbuttonImage, buttonImageHover, buttonImageActive, buttonSlice, buttonSliceScale
InputinputImage, inputSlice, inputSliceScale
OverlayssurfaceImage, surfaceSlice, surfaceSliceScale
CheckboxcheckboxImage, checkboxImageChecked, checkboxBorderWidth
RadioradioImage, radioImageChecked, radioRadius, radioBorderWidth, radioDotColor
SwitchswitchTrackImage, switchTrackImageChecked, switchKnobImage, switchRadius, switchKnobRadius
SlidersliderRailImage, sliderThumbImage, sliderRadius, sliderThumbRadius, sliderRailSlice, sliderRailSliceScale
const skin: ThemeTokens = {
    ...darkTheme,
    cardImage: `resource("MyTheme/panel")`,
    cardSlice: "16",
    buttonImage: `resource("MyTheme/button")`,
    buttonSlice: "6",
    checkboxImage: `resource("MyTheme/check-off")`,
    checkboxImageChecked: `resource("MyTheme/check-on")`,
    checkboxBorderWidth: "0", // unframed: the sprite is the frame
}

Good to know:

  • Overlays covers Dialog, Popover, DropdownMenu, Drawer, and Select's menu. Toast and Tooltip stay flat.
  • buttonImage skins the filled intents only; secondary / ghost stay frameless.
  • radioDotColor: "rgba(0, 0, 0, 0)" hides the native dot when it's baked into the selected-ring sprite.

Applying a theme without ThemeProvider#

applyTheme(tokens) is a plain function if you want to manage the render root yourself (or theme a secondary panel):

import { applyTheme, darkTheme } from "onejs-ui"

applyTheme(darkTheme) // emits --ojs-* onto the render root

<ThemeProvider> wraps this and additionally initializes the focus-visible manager, which is why it's the recommended entry point.