Skip to content

Writing a theme

A theme is plain data. There’s no JSX, no React state, no build step beyond TypeScript. You author it as a typed object (or a function that returns one) and the library renders the desktop with it.

import type { OsTheme } from "@react-ui-os/core";
export const myTheme: OsTheme = {
id: "my-theme",
name: "My theme",
palette: { … },
shape: { … },
motion: { … },
blur: { … },
wallpaper: { … },
chrome: { … },
customizable: { … },
};

See Themes overview for what each category contains.

Themes that need assets the consumer provides (a wallpaper image, a brand accent) should expose a factory function, not a static object. This keeps the theme package free of bundled binary assets and lets every consumer point at their own paths.

export interface MyThemeOptions {
wallpaperSrc?: string;
accent?: string;
}
export function createMyTheme(opts: MyThemeOptions = {}): OsTheme {
return {
id: "my-theme",
name: "My theme",
palette: { /* … */, accent: opts.accent ?? "#5cb6b9" },
wallpaper: { src: opts.wallpaperSrc, parallax: true, vignette: true },
// …
};
}

Consumers then call it at boot:

const theme = createMyTheme({ wallpaperSrc: "/wallpaper.jpg" });
<Desktop apps={apps} theme={theme} />;

The library does not bundle wallpaper images, fonts, or icons into themes. Reasons:

  • Tree-shaking. A theme that bundles a 200 KB JPEG forces every consumer to ship it.
  • Customization. Half the value of a theme is letting consumers swap the wallpaper for their own.
  • Licensing. Assets and code have different license stories.

Consumers serve their own assets from public/ (or equivalent), pass the URL to the factory, and the theme writes it into wallpaper.src.

Themes opt in to runtime tweaks by declaring customizable:

customizable: {
"palette.accent": {
kind: "color-from-palette",
section: "Appearance",
label: "Accent",
options: ["#5cb6b9", "#7c66f5", "#a855f7", "#ec4899"],
},
"chrome.dockPosition": {
kind: "select",
section: "Layout",
label: "Dock position",
options: [
{ value: "bottom", label: "Bottom" },
{ value: "left", label: "Left" },
{ value: "hidden", label: "Hidden" },
],
},
}

The full reference is at Customizable schema.

Some rules of thumb from theme-macos and theme-ubuntu:

  • Surface colors with alpha. rgba(20, 22, 32, 0.62) lets blur.surface actually frost something. Solid colors defeat the effect.
  • Open animations at 180-220 ms. Faster reads as snappy; slower reads as sluggish. Ubuntu runs 200 ms, matching the GNOME Shell window-open timing.
  • Genie minimize at 280-320 ms. Long enough to read as physical, short enough not to drag.
  • One blur value for surface, one for Spotlight. Spotlight typically wants a deeper blur because it floats over everything.
  • palette.border at rgba(255,255,255,0.08-0.10). Hairlines should feel intentional but not bright.

A clone usually wants its platform’s real chrome metrics, not the defaults. Optional chrome tokens size the dock and top bar; a "bar" dock derives its thickness from the tile size, so a larger-icon dock becomes a wider bar.

chrome: {
dockStyle: "bar",
dockTileSize: 40, // the dock tile / taskbar button (macOS 56, Windows 40, Ubuntu 56)
dockIconScale: 0.6, // the icon as a fraction of the tile
menuBarHeight: 30, // the top bar height (macOS 24, the GNOME bar ~30)
},

With these, Windows lands 24px icons in a 48px taskbar and Ubuntu ~46px icons in a ~64px dock. All three are optional; omit them to keep the shared defaults. Compact viewports shrink the tiles in step.

theme.font sets the platform’s UI font stack, applied at the desktop root so the chrome inherits it. The consumer still loads any non-system webfont (a @font-face rule or a font-host link), the same asset-ownership split as wallpapers.

font: '"Ubuntu", "Ubuntu Sans", system-ui, sans-serif',

chrome.iconStyle ("fluent", "macos", "gnome", or your own key) declares which icon variant the theme wants. An app that ships an icons[style] entry renders its platform-native icon under that theme; otherwise it falls back to its default icon. This is how one app carries a Fluent icon on Windows and an SF-style glyph on macOS without a component ever branching on the theme.

chrome: {
iconStyle: "fluent";
}

Themes are tiny, single-file packages. The Ubuntu theme is ~175 lines. A minimal package.json:

{
"name": "@my-org/theme-acme",
"version": "1.0.0",
"type": "module",
"main": "./dist/index.js",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"dependencies": {
"@react-ui-os/core": "^1.0.0"
}
}

Build with tsup (ESM + CJS + types) and you’re done.