Getting Started with yui
yui is a declarative, flexbox-based UI library for modern C++. If you’ve used React, Flutter, or SwiftUI, you’ll feel right at home — yui brings the same component-driven, reactive paradigm to C++20.
Component App() {
return [](ComponentContext& ctx) -> VNode {
auto [name, setName] = ctx.useState<std::string>("World");
return Column({
Text("Hello, " + name + "!").fontSize(24).color(0xFFFFFFFF),
Text("Build beautiful UIs with C++").color(0xAAAAAAFF),
}).gap(8).padding(20).alignItems(AlignItems::Center);
};
}
This guide will walk you through everything you need to build your first yui application.
New in 1.1: a standard widget set (Button, Checkbox, Switch, RadioGroup, Progress, Slider, Tabs, Select), overlays (Portal, Modal, Tooltip), full text editing with selection and clipboard, pointer capture + drag, and generalized focus. Upgrading from 1.0? See the migration guide.
Requirements
- C++20 compiler: GCC 10+, Clang 12+, or MSVC 2019+
- CMake 3.16+
- A rendering backend: SDL2 or NanoVG (we’ll cover both)
Quick Start
1. Clone the Repository
git clone --recursive https://github.com/dustinlacewell/yoga-ui.git
cd yoga-ui
The --recursive flag is important — it pulls in the Yoga layout engine and NanoVG submodules.
2. Install Backend Dependencies
Choose a rendering backend. SDL2 is simpler to set up; NanoVG offers smoother graphics.
Ubuntu/Debian:
# SDL2 backend
sudo apt install libsdl2-dev libsdl2-ttf-dev libsdl2-gfx-dev
# NanoVG backend
sudo apt install libglfw3-dev libglew-dev
macOS:
# SDL2 backend
brew install sdl2 sdl2_ttf sdl2_gfx
# NanoVG backend
brew install glfw glew
Windows (MSYS2/MinGW):
# SDL2 backend
pacman -S mingw-w64-x86_64-SDL2 mingw-w64-x86_64-SDL2_ttf mingw-w64-x86_64-SDL2_gfx
# NanoVG backend
pacman -S mingw-w64-x86_64-glfw mingw-w64-x86_64-glew
3. Build and Run
cmake -B build
cmake --build build --target hello_world
./build/bin/hello_world
You should see a window with “Hello, World!” centered on screen.
Core Concepts
Primitives
yui provides five primitive node types — the building blocks of every UI:
| Primitive | Purpose | Example |
|---|---|---|
Box | Layout container (like HTML <div>) | Box({ child1, child2 }) |
Text | Display text | Text("Hello") |
Input | Text input field | Input().value(text) |
Scroll | Scrollable container | Scroll(content) |
Canvas | Custom drawing | Canvas(drawFn) |
Components
There are two kinds of components:
Helper functions return VNodes directly. Simple, no state:
VNode LabeledField(std::string label, VNode field) {
return Column(
Text(label).color(0xAAAAAAFF).fontSize(11),
field
).gap(4);
}
yui also ships a standard set of interactive controls — buttons, checkboxes,
sliders, tabs, dropdowns, and more — as yui::widgets, so you don’t need to
hand-roll a Button helper yourself:
#include <yui/yui.hpp> // pulls in yui::widgets too
using namespace yui::widgets;
Button("Click me").onClick([] { std::cout << "clicked\n"; });
See Widgets for the full set.
Stateful components use Component() with hooks for local state, effects, and selective re-rendering:
Component ClickCounter() {
return [](ComponentContext& ctx) -> VNode {
auto [count, setCount] = ctx.useState(0);
return Box(Text("Clicks: " + std::to_string(count)))
.padding(12)
.onClick([=] { setCount(count + 1); });
};
}
Both are first-class children — mix freely:
Column({
Text("Welcome!"), // VNode (primitive)
Button("Press me", handler), // VNode (helper function)
ClickCounter(), // Component (stateful)
})
The Fluent API
Every node supports a fluent API for setting properties. Chain methods to configure layout, styling, and behavior:
Box({ ... })
// Layout
.width(200).height(100)
.padding(16).margin(8).gap(12)
.flexGrow(1)
.flexDirection(FlexDirection::Row)
.justifyContent(JustifyContent::Center)
.alignItems(AlignItems::Center)
// Styling
.backgroundColor(0x1a1a1aFF)
.borderColor(0x404040FF)
.borderWidth(1)
.borderRadius(8)
// Events
.onClick([] { /* handle click */ })
.onHover([](bool hovered) { /* handle hover */ })
// State-based styles
.hoverStyle(BoxStyle{.backgroundColor = 0x2a2a2aFF})
.focusStyle(BoxStyle{.borderColor = 0x4a9fffFF});
onClick fires only when the press and the release land on the same
node — if the pointer moves off the element before release (e.g. it’s
dragged away, or something else covers it mid-press), no onClick fires on
either node. This matters most for elements that might get covered or moved
mid-interaction (an item in a reordering list, a button near an opening
overlay).
Layout with Flexbox
yui uses Yoga, Facebook’s flexbox implementation, for layout. If you know CSS flexbox, you know yui layout:
// Horizontal row with centered items
Row(a, b, c)
.justifyContent(JustifyContent::SpaceBetween)
.alignItems(AlignItems::Center)
// Vertical column with gaps
Column(header, content, footer)
.gap(16)
// Flexible sizing
Row(
Box(sidebar).width(200), // Fixed width
Box(content).flexGrow(1) // Takes remaining space
)
// Absolute positioning
Box(overlay)
.positionType(PositionType::Absolute)
.positionTop(0).positionLeft(0).positionRight(0)
Layout helpers:
Row(...)— Horizontal flex containerColumn(...)— Vertical flex containerSpacer()— Flexible space that grows to fillGap(size)— Fixed-size spacing
Prefer bare arguments over a brace-enclosed list: Row(a, b, c) forwards
each argument straight into a child slot; Row({a, b, c}) builds an
intermediate std::vector<Child> first and is measurably slower. Both still
compile and behave identically — reach for the brace-list form only when the
children come from a runtime-built vector (e.g. produced by a loop), not for
a fixed set of children you’re writing out by hand.
Your First App
Let’s build a counter app using stateful components. Create a new file counter.cpp:
#include "yui/yui.hpp"
#include "yui/sdl/sdl.hpp"
#include <SDL.h>
#include <SDL_ttf.h>
using namespace yui;
// Stateful counter component
Component Counter() {
return [](ComponentContext& ctx) -> VNode {
auto [count, setCount] = ctx.useState(0);
return Column({
Text("Count: " + std::to_string(count))
.fontSize(32)
.color(0xFFFFFFFF),
Row({
Box(Text("-").fontSize(24).color(0xFFFFFFFF))
.padding(16)
.backgroundColor(0xDD4444FF)
.borderRadius(8)
.onClick([=] { setCount(count - 1); }),
Gap(16),
Box(Text("+").fontSize(24).color(0xFFFFFFFF))
.padding(16)
.backgroundColor(0x44AA44FF)
.borderRadius(8)
.onClick([=] { setCount(count + 1); }),
}),
})
.gap(24)
.padding(32)
.alignItems(AlignItems::Center)
.justifyContent(JustifyContent::Center)
.flexGrow(1)
.backgroundColor(0x1a1a2eFF);
};
}
class CounterHost : public Host {
public:
CounterHost(SDL_Renderer* r, const std::string& fontPath)
: renderer_(r, fontPath, 16) {
setTextMeasurer(&renderer_);
setRender(Counter());
}
void frame(int w, int h) {
update(w, h, 1.0f / 60.0f);
renderer_.render(root());
}
private:
sdl::SdlRenderer renderer_;
};
int main() {
if (!sdl::initSDL()) return 1;
SDL_Window* window = SDL_CreateWindow(
"Counter", SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED,
400, 300, SDL_WINDOW_SHOWN
);
SDL_Renderer* renderer = SDL_CreateRenderer(
window, -1, SDL_RENDERER_ACCELERATED | SDL_RENDERER_PRESENTVSYNC
);
// Find a system font
std::string fontPath;
const char* fonts[] = {
"C:/Windows/Fonts/segoeui.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/System/Library/Fonts/Helvetica.ttc",
nullptr
};
for (const char** p = fonts; *p; p++) {
if (TTF_OpenFont(*p, 16)) { fontPath = *p; break; }
}
CounterHost host(renderer, fontPath);
bool running = true;
while (running) {
SDL_Event event;
while (SDL_PollEvent(&event)) {
if (event.type == SDL_QUIT) running = false;
if (event.type == SDL_KEYDOWN && event.key.keysym.sym == SDLK_ESCAPE)
running = false;
// Forward mouse events to yui
if (event.type == SDL_MOUSEBUTTONDOWN)
host.handleMouseDown(event.button.x, event.button.y, MouseButton::Left);
if (event.type == SDL_MOUSEBUTTONUP)
host.handleMouseUp(event.button.x, event.button.y, MouseButton::Left);
if (event.type == SDL_MOUSEMOTION)
host.handleMouseMove(event.motion.x, event.motion.y);
}
int w, h;
SDL_GetWindowSize(window, &w, &h);
SDL_SetRenderDrawColor(renderer, 0, 0, 0, 255);
SDL_RenderClear(renderer);
host.frame(w, h);
SDL_RenderPresent(renderer);
}
SDL_DestroyRenderer(renderer);
SDL_DestroyWindow(window);
sdl::quitSDL();
return 0;
}
Key points:
Counter()returns aComponent— a deferred render function with hook accessuseState(0)creates local state; the settersetCounttriggers a re-render of just this componentHost::setRender()accepts either aComponentor astd::function<VNode()>
Reactive State with Store
Store<T> is yui’s reactive state container for shared/global state. When a Store changes, subscribed components automatically re-render:
// Define state outside your components
Store<int> counter(0);
Store<std::string> username("");
Store<std::vector<Todo>> todos;
Inside a stateful component, use() subscribes only that component — other components don’t re-render:
auto CounterDisplay = [&](ComponentContext& ctx) -> VNode {
int n = counter.use(); // Subscribe just this component
return Text("Count: " + std::to_string(n));
};
auto StaticLabel = [](ComponentContext& ctx) -> VNode {
return Text("I never re-render"); // No subscription, never re-renders
};
In the top-level render function, use() subscribes the entire host:
host.setRender(std::function<VNode()>([&]() {
int n = counter.use(); // Subscribes the whole host (full re-render on change)
return Column({ Text(std::to_string(n)) });
}));
Modify state:
counter.set(42); // Replace value
counter.set([](int& n) { n++; }); // Mutate in place
todos.set([](auto& t) { t.push_back(...); }); // Works with vectors
Threading: yui is single-threaded —
Host::update(), event handling, and building VNodes all run on one thread (the UI/main thread).Store::set()is the only operation you may call from another thread (e.g. an audio, network, or worker thread): it just marks subscribers dirty (via a release/acquire handoff of the dirty flags), and the re-render is applied on the host thread at the nextupdate(). Do not calluse(),peek(), or anyHostmethod off the host thread.
use()andpeek()return the store value by value (a copy taken under the store’s lock), not a reference — so there is no reference to escape and no dangling read if another thread’sset()replaces the value while you hold it.
Best practices:
- Prefer component-level
use()for selective re-rendering - Use
peek()to read without subscribing (for event handlers) - Use
set()with a lambda for complex updates - For component-local state, prefer
useStateover Store
Handling Events
Mouse Events
Box(Text("Click me"))
.onClick([] {
std::cout << "Left clicked!\n";
})
.onRightClick([] {
std::cout << "Right clicked!\n";
})
.onMiddleClick([] {
std::cout << "Middle clicked!\n";
})
.onHover([](bool hovered) {
std::cout << (hovered ? "Mouse entered" : "Mouse left") << "\n";
});
Keyboard Events
Box(content)
.onKeyDown([](int keyCode, uint16_t modifiers, bool repeat) {
if (modifiers & KeyMod_Ctrl && keyCode == 'S' && !repeat) {
save();
}
});
For focus, Tab traversal, and routing real text-editing keys into a focused
Input, see Focus and Text
Editing — both build on a richer
Host::handleKeyDown overload than the raw form shown above.
Input Fields
Input uses a controlled pattern — you set the value and handle changes:
// In a stateful component:
auto [email, setEmail] = ctx.useState<std::string>("");
Input()
.value(email)
.placeholder("you@example.com")
.fontSize(14)
.padding(12)
.borderRadius(4)
.backgroundColor(0x333333FF)
.onChange([=](const std::string& value) {
setEmail(value);
})
.onSubmit([=] {
submit(email);
});
Or use useField for two-way Store binding:
auto [email, setEmail] = ctx.useField(formStore, &FormState::email);
Input()
.value(email)
.onChange([=](const std::string& v) { setEmail(v); });
Conditional Rendering
// Show only if condition is true
When(isLoggedIn, UserProfile())
// Choose between two alternatives
If(isLoading,
Spinner(),
Content()
)
// Both preserve position for stable reconciliation
Column({
Header(),
When(showBanner, PromoBanner()), // Space reserved even when hidden
MainContent(),
})
Rendering Lists
For dynamic lists, use the List helper with keys for efficient updates:
struct Todo {
int id;
std::string text;
bool done;
};
VNode TodoList(const std::vector<Todo>& todos) {
return List(todos,
// Key function - returns unique identifier
[](const Todo& t) { return t.id; },
// Render function - returns VNode for each item
[](const Todo& t) {
return Row({
Text(t.text)
.color(t.done ? 0x888888FF : 0xFFFFFFFF)
.flexGrow(1),
Text(t.done ? "done" : "todo")
.onClick([id = t.id] { toggleTodo(id); }),
}).padding(8);
}
);
}
// Horizontal lists
HList(tabs,
[](const Tab& t) { return t.id; },
[](const Tab& t) { return TabButton(t); }
)
Render functions can return either VNode or Component — use Component when list items need their own local state.
Keys let the reconciler identify which items changed, were added, or removed across a re-render, so only the changed items update instead of the whole list.
State-Based Styles
Apply visual changes on hover or focus without manual state tracking:
// Button with hover effect
Box(Text("Submit"))
.backgroundColor(0x3366FFFF)
.hoverStyle(BoxStyle{
.backgroundColor = 0x4477FFFF,
.borderColor = 0x88AAFFFF
})
// Input with focus indicator
Input()
.value(text)
.borderColor(0x444444FF)
.borderWidth(1)
.hoverStyle(InputStyle{.borderColor = 0x666666FF})
.focusStyle(InputStyle{.borderColor = 0x4a9fffFF})
Style structs use std::optional — only set the properties you want to override.
Custom Drawing with Canvas
For graphics, games, or custom visualizations, use the Canvas primitive:
VNode ProgressRing(float progress) {
return Canvas([progress](void* ctx, float w, float h) {
auto* vg = static_cast<NVGcontext*>(ctx);
float cx = w / 2, cy = h / 2;
float radius = std::min(w, h) / 2 - 4;
// Background circle
nvgBeginPath(vg);
nvgCircle(vg, cx, cy, radius);
nvgStrokeColor(vg, nvgRGBA(60, 60, 60, 255));
nvgStrokeWidth(vg, 4);
nvgStroke(vg);
// Progress arc
float startAngle = -NVG_PI / 2;
float endAngle = startAngle + progress * NVG_PI * 2;
nvgBeginPath(vg);
nvgArc(vg, cx, cy, radius, startAngle, endAngle, NVG_CW);
nvgStrokeColor(vg, nvgRGBA(74, 222, 128, 255));
nvgStrokeWidth(vg, 4);
nvgLineCap(vg, NVG_ROUND);
nvgStroke(vg);
}).width(64).height(64);
}
The draw function receives:
ctx— Renderer-specific context (NVGcontext* for NanoVG, SDL_Renderer* for SDL)w,h— Canvas dimensions after layout
Scrollable Content
Wrap content in Scroll for scrollable containers:
Scroll(
Column({
// Many items that exceed container height
...items
})
)
.width(300)
.height(400)
.backgroundColor(0x1a1a1aFF)
Scroll handles mouse wheel events automatically. Content that exceeds bounds is clipped and scrollable.
Project Integration
Option 1: CMake FetchContent (Recommended)
include(FetchContent)
FetchContent_Declare(
yui
GIT_REPOSITORY https://github.com/dustinlacewell/yoga-ui.git
GIT_TAG v0.1.0
GIT_SHALLOW TRUE
)
FetchContent_MakeAvailable(yui)
target_link_libraries(your_app PRIVATE yui::yui)
Option 2: Git Submodule
git submodule add https://github.com/dustinlacewell/yoga-ui.git deps/yui
git submodule update --init --recursive
add_subdirectory(deps/yui)
target_link_libraries(your_app PRIVATE yui)
Option 3: System Install
git clone --recursive https://github.com/dustinlacewell/yoga-ui.git
cd yoga-ui
cmake -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build
sudo cmake --install build
find_package(yui REQUIRED)
target_link_libraries(your_app PRIVATE yui::yui)
Choosing a Backend
SDL2 Backend
Best for: Simple apps, games with 2D graphics, cross-platform desktop apps.
#include "yui/sdl/sdl.hpp"
#include "yui/sdl/SdlRenderer.hpp"
yui::sdl::SdlRenderer renderer(sdlRenderer, "path/to/font.ttf", 16);
host.setTextMeasurer(&renderer); // install on your Host so Text is measured
Dependencies: SDL2, SDL2_ttf, SDL2_gfx
NanoVG Backend
Best for: Polished UIs, smooth animations, high-DPI displays, hardware acceleration.
#include "yui/nvg/nvg.hpp"
#include "yui/nvg/NvgRenderer.hpp"
int fontId = nvgCreateFont(vg, "default", "path/to/font.ttf");
yui::nvg::NvgRenderer renderer(vg, fontId);
host.setTextMeasurer(&renderer); // install on your Host so Text is measured
Dependencies: GLFW3, GLEW, OpenGL
Example Applications
The examples/ directory contains complete applications demonstrating yui’s capabilities:
| Example | Description | Build Target |
|---|---|---|
hello_world.cpp | Minimal “Hello World” | hello_world |
sdl_showcase.cpp | SDL2 feature showcase | sdl_showcase |
nvg_showcase.cpp | NanoVG feature showcase | nvg_showcase |
todo_app.cpp | Complete todo list app | todo_app |
pong.cpp | Pong game with Canvas | pong |
benchmark.cpp | Performance stress test | benchmark |
Build all examples:
cmake --build build --target all_examples
Architecture Overview
┌──────────────────────────────────────────────────────────────────────┐
│ Each Frame │
├──────────────────────────────────────────────────────────────────────┤
│ │
│ Store.set() ───► marks dirty (component or host) │
│ │
│ Host::update() │
│ 1. Re-render dirty components (selective) │
│ 2. Full reconcile (if host dirty): │
│ render() ──► VNode tree ──► Reconciler │
│ ├─► Fiber tree (state, hooks) │
│ └─► Render tree (layout, draw) │
│ 3. Layout (Yoga) on render tree │
│ 4. Renderer draws render tree │
│ 5. EventHandler dispatches to render tree nodes │
│ │
└──────────────────────────────────────────────────────────────────────┘
VNode — Lightweight description of desired UI. Created fresh each frame.
Component — Deferred render function with access to hooks (useState, useEffect, etc.).
Fiber — Internal tree tracking component identity, state, and subscriptions.
Node — Actual widget instance. Persists across frames. Holds layout and hover/focus state.
Reconciler — Diffs VNode/Component tree against existing Fiber+Node trees. Reuses nodes where possible, preserving state.
Next Steps
Now that you understand the basics, explore these resources:
- Primitives Reference — Complete API for Box, Text, Input, Scroll, Canvas
- Components Guide — Hooks, Store subscriptions, and composition patterns
- Architecture Deep Dive — Dual-tree architecture, reconciliation, selective re-rendering
- Extending yui — Adding custom primitives
Happy building!