Skip to main content

LVGL Design System

Overview​

ESPCompose uses TypeScript/TSX syntax to define UIs that compile into ESPHome YAML with LVGL widgets. While the authoring experience feels similar to React, the output is fundamentally different — there is no virtual DOM, no re-rendering, and no JavaScript at runtime.

Instead, ESPCompose is a declarative UI compiler: you write component trees in TSX, and the compiler produces a static LVGL widget tree with reactive C++ signal-driven updates.


Mental Model​

What It Is​

  • A compile-time DSL that produces a static widget tree
  • Reactive updates happen via a C++ signal graph (Signal / Memo), not re-renders
  • UI changes are applied by mutating existing widgets through compiled effects

What It Is Not​

  • Not a React runtime — no virtual DOM, no component lifecycle, no useState
  • Not a browser environment — there is no CSS, no DOM, no HTML

Reactivity​

Reactivity in ESPCompose means reactive signals compiled into a C++ dependency graph that drives imperative widget updates.

const sensor = useHAEntity('sensor.temperature');

<Text text={useMemo(() => `${sensor.value}°`)} />

This compiles to a C++ Memo<std::string> tracking a Signal<float> from the Home Assistant sensor. When the sensor value changes, the memo recomputes and the widget text updates via lv_label_set_text().


Design System Goals​

  1. Provide a high-level, ergonomic API for building embedded UIs
  2. Hide LVGL complexity behind semantic components
  3. Allow low-level escape hatches when needed (x:custom, intrinsic elements)
  4. Compile cleanly into LVGL widgets + ESPHome automations + C++ lambdas
  5. Support runtime theme switching through the reactive signal graph

Architecture: Two Layers​

Intrinsic Elements (Low-level)​

Thin wrappers over LVGL widgets for full control:

<lvgl-label />
<lvgl-button />
<lvgl-slider />

These are useful for advanced cases or when you need direct access to LVGL properties.

High-level, semantic components from @espcompose/ui:

<Screen />
<Text />
<Button />
<Card />
<VStack />
<Slider />
<Switch />
<Dropdown />

These provide consistency, theme integration, and a simpler API for most use cases.


Core Concepts​

Static Widget Tree​

All components compile into a fixed tree. There is no dynamic insertion or removal of widgets at runtime — only visibility and property changes.

<VStack>
<Text text="Hello" />
<Button text="Press Me" />
</VStack>

Reactive Bindings​

Bindings connect data sources to widget properties through the reactive system:

const sensor = useHAEntity('sensor.temperature');
const temp = useMemo(() => `${sensor.value}°`);

<Text text={temp} />

This compiles to:

  • A Signal<float> for the HA sensor
  • A Memo<std::string> for the formatted string
  • An effect that calls lv_label_set_text() when the memo changes

Binding Sources​

SourceHookDescription
Home Assistant entitiesuseHAEntity()Typed signal properties for sensors, lights, switches, etc.
Theme tokenshandle.use()Reactive access to the current theme values
Derived valuesuseMemo()Computed values that track their dependencies

Event → Action Model​

Event handlers are compiled at the AST level into ESPHome action sequences. No JavaScript runs at runtime.

<Button
text="Kitchen"
onPress={async () => { kitchenLight.toggle(); }}
/>

Compiles to light.toggle: { id: kitchen_light }.

Visibility Instead of Conditional Rendering​

Since the widget tree is static, use visibility toggling instead of conditional rendering:

// Correct — toggles LVGL hidden flag
<lvgl-obj hidden={useMemo(() => !sensor.isOn)}>
<Text text="Light is on" />
</lvgl-obj>
// Incorrect — conditional rendering is not supported
{isOn && <Light />}

Styling: Parts + States​

LVGL uses a parts and states model, not CSS selectors.

Parts describe sub-elements of a widget: main, indicator, knob, items, scrollbar, selected, ticks, cursor

States describe interactive conditions: default, pressed, focused, checked, disabled, hovered, scrolled

Style properties are applied to a specific part + state combination. Design system components handle most of this automatically through the theme system.


Design Tokens​

Use semantic tokens instead of raw style values:

<Button status="primary" size="md" />

Instead of:

<lvgl-button bgColor="#123456" />

Tokens are resolved through the reactive theme system:

  • status → colors from the theme palette (e.g., primary, success, danger)
  • size → dimensions from the theme scale (e.g., sm, md, lg)
  • spacing → padding/gap from the theme spacing scale

Layout​

Prefer layout components over absolute positioning:

<VStack gap="md">
<Text text="Title" />
<Button text="Action" />
</VStack>

Available Layout Components​

ComponentDescription
VStack / HStackFlex column/row with gap, padding, alignment
SpaceSpacer element for flex layouts
Row / ColProportional flex grid (column spans)
Grid / GridItemCSS Grid layout with FR(n) track sizing

The style prop on any component accepts CSS-like layout properties (display, flexDirection, justifyContent, alignItems, gap, etc.) which are compiled to their LVGL equivalents.


Theme System​

Themes are registered at compile time and switchable at runtime:

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

<UITheme.Provider default="dark">
<Screen>
<Button
text="Switch to Light"
onPress={async () => { UITheme.select('light'); }}
/>
</Screen>
</UITheme.Provider>

Inside components, theme values are reactive:

const thm = UITheme.use();
const bgColor = thm.colors.primary.bg; // Reactive — updates when theme changes

Theme switching compiles to theme_index.set(N); Scheduler::flush(); — all downstream memos recalculate and widgets update automatically.


Refs​

Components that need to be referenced from actions require a useRef():

const lightRef = useRef<Light>();

<light platform="homeassistant" ref={lightRef} entityId="light.kitchen" />

<Button onPress={async () => { lightRef.toggle(); }} />

Refs generate collision-resistant IDs and are resolved to ESPHome YAML IDs by the compiler.


Reactive Expressions​

Expressions inside useMemo() are compiled to C++. Only a supported subset of JavaScript is allowed:

Supported:

useMemo(() => `${sensor.value}°`)
useMemo(() => sensor.isOn ? "On" : "Off")

Not supported:

  • Arbitrary JS logic or control flow
  • Async operations
  • Closures with external mutable state
  • Importing external JS libraries

Hooks Reference​

HookDescription
useHAEntity(entityId)Bind to a Home Assistant entity; returns typed signal properties
useMemo(fn)Derive a value from reactive sources
handle.use()Access reactive theme token values
useRef<T>()Create a typed cross-component reference
useScript(fn)Define a named ESPHome script from an async arrow function
useImage(path)Register an image asset
useFont(config)Register a font asset

Components Reference​

Display​

ComponentDescriptionKey Props
ScreenRoot display containerstyle
TextTypographytext, variant, color, align, longMode, style
ImageImage displaysrc, style
SpinnerLoading indicatorstyle

Layout​

ComponentDescriptionKey Props
VStack / HStackFlex column/rowgap, padding, align, crossAlign, wrap, style
SpaceSpacer elementsize, style
Row / ColProportional flex gridspan (on Col), style
Grid / GridItemCSS Grid layoutcolumns, rows, gap, alignColumns, alignRows, style
CardSurface containerpadding, radius, gap, style

Input​

ComponentDescriptionKey Props
ButtonStatus-aware buttontext, children, status, size, variant, onPress, style
SliderThemed slidervalue, min, max, onChange, style
SwitchThemed togglevalue, onChange, style
DropdownDropdown selectoroptions, value, onChange, style

Binding-Driven​

ComponentDescriptionKey Props
SensorTextText display from a sensor bindingbinding, text, variant, color

Full Example​

import { useHAEntity, useMemo, secret } from '@espcompose/core';
import {
UITheme, Screen, VStack, HStack, Card,
Text, Slider, Switch, SensorText,
} from '@espcompose/ui';

function App() {
const light = useHAEntity('light.living_room');
const sensor = useHAEntity('sensor.temperature');

return (
<esphome name="dashboard">
<esp32 board="esp32dev" framework={{ type: 'esp-idf' }} />
<wifi ssid={secret('wifi_ssid')} password={secret('wifi_password')} />
<api />
<lvgl>
<UITheme.Provider default="dark">
<Screen>
<VStack gap="lg" padding="lg">
<Card>
<Text variant="title" text="Living Room" />
<HStack gap="xs">
<Text text="Temperature:" />
<SensorText binding={sensor} />
</HStack>
<VStack gap="xs">
<Text text="Brightness" />
<Slider
value={isNaN(light.brightness) ? 0 : light.brightness}
onChange={(args) => { light.turnOn({ brightness: args.x }); }}
min={0}
max={255}
/>
</VStack>
<HStack align="spaceBetween" crossAlign="center">
<Text text="Lamp" />
<Switch
value={light.isOn}
onChange={() => { light.toggle(); }}
/>
</HStack>
</Card>
</VStack>
</Screen>
</UITheme.Provider>
</lvgl>
</esphome>
);
}

export default <App />;

Common Mistakes​

// ❌ React state doesn't exist
const [value, setValue] = useState(0);

// ❌ Conditional rendering — tree must be static
{value > 10 && <Text text={value} />}

// ❌ HTML elements don't exist
<div>...</div>

These patterns don't work because ESPCompose produces a fixed widget tree at compile time. Use useMemo() for derived values, visibility toggling for conditional display, and LVGL/design system components for all UI elements.