Skip to main content

Theming

The @espcompose/ui design system is fully theme-driven. All visual decisions — colors, spacing, typography, border radii, and component sizes — are controlled by a theme object.

Terminology:

  • ThemeDefinition — the shape/contract interface that describes what a theme contains (tokens, colors, typography, etc.)
  • Theme — a concrete instance that implements the definition, such as darkTheme or lightTheme

Themes can be switched at runtime on the device.

Theme structure​

A theme implements the ThemeDefinition interface:

interface ThemeDefinition {
name: string;
colors: ThemeColors;
typography: ThemeTypography;
spacing: Record<SpacingToken, number>;
radii: Record<RadiusToken, number>;
sizes: Record<SizeToken, SizeDimensions>;
parts?: ThemeParts;
}

Colors​

interface ThemeColors {
// Semantic status palettes — each has bg, text, and bgPressed
primary: StatusColors;
secondary: StatusColors;
success: StatusColors;
warning: StatusColors;
danger: StatusColors;

// Surface colors
background: HexColor;
surface: HexColor;
surfaceAlt: HexColor;
border: HexColor;

// Text colors
textPrimary: HexColor;
textSecondary: HexColor;
textDisabled: HexColor;
}

Typography​

interface ThemeTypography {
title: FontToken;
subtitle: FontToken;
body: FontToken;
caption: FontToken;
}

Font tokens are created with createFontToken(file, size):

import { createFontToken } from '@espcompose/core';

const myTitle = createFontToken('gfonts://Roboto', 28);

Spacing and radii​

// Spacing (in pixels)
spacing: {
none: 0, xs: 4, sm: 8, md: 16, lg: 24, xl: 32,
}

// Border radii (in pixels)
radii: {
none: 0, sm: 4, md: 8, lg: 16, full: 9999,
}

Component sizes​

sizes: {
xs: { height: 28, font: createFontToken('gfonts://Roboto', 12), paddingX: 8, paddingY: 4 },
sm: { height: 36, font: createFontToken('gfonts://Roboto', 14), paddingX: 12, paddingY: 6 },
md: { height: 44, font: createFontToken('gfonts://Roboto', 16), paddingX: 16, paddingY: 8 },
lg: { height: 52, font: createFontToken('gfonts://Roboto', 18), paddingX: 20, paddingY: 10 },
xl: { height: 64, font: createFontToken('gfonts://Roboto', 22), paddingX: 24, paddingY: 12 },
}

Widget part colors​

Optional colors for widget internals (slider track/knob, switch rail, etc.):

parts: {
slider: { indicator: '#1E88E5', knob: '#E0E0E0', rail: '#3A3A3A' },
switch: { indicator: '#1E88E5', rail: '#3A3A3A', knob: '#E0E0E0' },
arc: { indicator: '#1E88E5', knob: '#E0E0E0' },
}

Built-in themes​

@espcompose/ui ships with two built-in themes:

Dark theme​

High contrast, dark surfaces optimized for OLED displays and varying ambient light.

import { darkTheme } from '@espcompose/ui';
TokenValue
Background#121212
Surface#1E1E1E
Primary#1E88E5
Text primary#E0E0E0

Light theme​

Light surfaces with dark text, suitable for well-lit environments.

import { lightTheme } from '@espcompose/ui';
TokenValue
Background#FAFAFA
Surface#FFFFFF
Primary#1565C0
Text primary#212121

Setting up ThemeProvider​

Wrap your UI in <UITheme.Provider> to enable theming. By default it registers both built-in themes with dark as the default:

import { UITheme } from '@espcompose/ui';

<lvgl displays={[displayRef]}>
<UITheme.Provider>
<Screen>
{/* Themed content */}
</Screen>
</UITheme.Provider>
</lvgl>

To choose a different default:

<UITheme.Provider default="light">
{/* Light theme is active on boot */}
</UITheme.Provider>

Runtime theme switching​

Switch themes on the device using UITheme.select() inside trigger handlers:

import { UITheme, Button, HStack } from '@espcompose/ui';

<HStack>
<Button
text="Dark"
status="primary"
onPress={() => { UITheme.select('dark'); }}
/>
<Button
text="Light"
status="secondary"
onPress={() => { UITheme.select('light'); }}
/>
</HStack>

All themed components update instantly — the C++ signal graph propagates the theme change to every widget.

Creating a custom theme​

Implement the ThemeDefinition interface and pass it to the provider:

import { createFontToken } from '@espcompose/core';
import type { ThemeDefinition } from '@espcompose/ui';

const montserrat = (size: number) => createFontToken('gfonts://Montserrat', size);

const oceanTheme: ThemeDefinition = {
name: 'Ocean',

colors: {
primary: { bg: '#006994', text: '#FFFFFF', bgPressed: '#004E6F' },
secondary: { bg: '#4A6572', text: '#FFFFFF', bgPressed: '#344955' },
success: { bg: '#2E7D32', text: '#FFFFFF', bgPressed: '#1B5E20' },
warning: { bg: '#F9A825', text: '#000000', bgPressed: '#F57F17' },
danger: { bg: '#C62828', text: '#FFFFFF', bgPressed: '#B71C1C' },

background: '#0A1628',
surface: '#122140',
surfaceAlt: '#1A2F52',
border: '#2A4060',

textPrimary: '#E8F0FE',
textSecondary: '#8EACCD',
textDisabled: '#4A6572',
},

typography: {
title: montserrat(28),
subtitle: montserrat(20),
body: montserrat(16),
caption: montserrat(12),
},

spacing: { none: 0, xs: 4, sm: 8, md: 16, lg: 24, xl: 32 },
radii: { none: 0, sm: 4, md: 8, lg: 16, full: 9999 },

sizes: {
xs: { height: 28, font: montserrat(12), paddingX: 8, paddingY: 4 },
sm: { height: 36, font: montserrat(14), paddingX: 12, paddingY: 6 },
md: { height: 44, font: montserrat(16), paddingX: 16, paddingY: 8 },
lg: { height: 52, font: montserrat(18), paddingX: 20, paddingY: 10 },
xl: { height: 64, font: montserrat(22), paddingX: 24, paddingY: 12 },
},

parts: {
slider: { indicator: '#006994', knob: '#E8F0FE', rail: '#2A4060' },
switch: { indicator: '#006994', rail: '#2A4060', knob: '#E8F0FE' },
arc: { indicator: '#006994', knob: '#E8F0FE' },
},
};

Then register it alongside the built-in themes using UITheme.extend():

import { UITheme } from '@espcompose/ui';

const MyTheme = UITheme.extend({ ocean: oceanTheme });

Use the extended handle's Provider and set the default:

<lvgl displays={[displayRef]}>
<MyTheme.Provider default="ocean">
<Screen>
{/* Your UI — now themed with Ocean */}
</Screen>
</MyTheme.Provider>
</lvgl>

Switch to any registered theme at runtime:

<Button text="Ocean" onPress={() => { MyTheme.select('ocean'); }} />
<Button text="Dark" onPress={() => { MyTheme.select('dark'); }} />

The extended handle is fully type-safe — MyTheme.select() and MyTheme.Provider know the available theme names ('dark' | 'light' | 'ocean') at compile time.

How it works under the hood​

  1. The Provider registers themes during the render pass
  2. Components call internal hooks (useStatus(), useSpacing(), etc.) that return reactive proxies
  3. The compiler generates C++ theme value arrays indexed by a theme_index signal
  4. Each theme leaf becomes a Memo<T> in the signal graph
  5. UITheme.select('dark') sets the theme index and flushes the scheduler — all widgets update instantly