Introduction

SableUI is a high-performance C++ UI framework that brings React's component model and Tailwind's styling approach to native applications - without the overhead of web technologies.

class Counter : public BaseComponent
{
public:
	void Layout() override
	{
		const Theme& t = GetTheme();

		Div(bg(t.surface0), p(30), centerXY, rounded(10))
		{
			Text(SableString::Format("Count: %d", count.get()), fontSize(28), mb(20), textWrap(false));

			Div(left_right)
			{
				Button("Increment", [this]() { count.set(count.get() + 1); }, mr(4));
				Button("Decrement", [this]() { count.set(count.get() - 1); });
			}
		}
	}

private:
	State<int> count{ this, 0 };
};



Card image

What is SableUI?

Purpose and design philosophies

Card image

Getting Started

Build a starter application in under 5 minutes

Card image

Core concepts

Understand concepts such as components, states and more

Card image

API reference

Learn how to build a anything using SableUI's layout system

Card image

Examples

See real applications

Card image

Advanced topics

To build complex applications and access deeper APIs

What is SableUI?

The driving force behind SableUI is to solve a problem with modern UI development. When building an application, web technologies are often preferable, but they come with heavy performance costs and annoying abstraction layers. SableUI brings modern UI development to a lower level with zero runtime overhead.

OUT OF DATE

Core Philosophy

Traditional C++ UI frameworks require verbose code that's often hard to read. Web frameworks like react solved this with declarative components, but at the cost of performance and introduces an abstraction layer between UI and application logic. SableUI attempts to bridge this gap.

Traditional C++ Pseudo-code

auto* button = new Button();
button->setText("Click me");
button->setPosition(10, 10);
button->onClick([&]() { count++; updateLabel(); });
layout->addWidget(button);
// OR
ElementInfo info{};
info.label = "Click me";
info.position = vec2(10, 10);
info.onClick = [&]() { count++; updateLabel(); };
AddButton(info);

These solutions are not reactive, meaning manual re-renders will have to be scripted to update the labels, and with more complex heirachies it becomes impossible to manage.

SableUI:

Div(onClick([this]() { setCount(count + 1); })) {
    Text("Click me");
}

Concise and reactive (comparable to react), setCount() marks the element as dirty, and will be automatically rerendered next frame.

Key Features

React-Inspired components

Components describe what should be rendered, not requireing definitions on how to update existing UI. State changes trigger automatic efficient rerenders through reconcilliation with a virtual DOM.

class TodoList : public SableUI::BaseComponent {
    void Layout() override {
        for (const auto& todo : todos) {
            Div(bg(45, 45, 45) p(10) mb(5)) {
                Text(todo.text);
            }
        }
    }
private:
    // When "todos" changes via "setTodos()", this component will be automatically re-rendered
    useState(todos, setTodos, std::vector<Todo>, {});
    /* ^^ This syntax is equivilant to reacts:
     * const [todos, setTodos] = useState<std::vector<Todo>>({});
     * but due to limitations within c++, useState is a macro that defines
     * member variables and setters to the component */
};

Learn more about components here and useState() and reactivity here.

Tailwind-Inspired Styling

Chainable modifiers make styling fact and readable, macros ensure expand in the pre-processor, ensuring no runtime-performance loss

Div(
    w(200) h(100)        // Fixed width and height
    bg(45, 45, 45)       // Background colour
    p(10) m(5)           // Padding & margin
    rounded(8)           // Border radius
    centerXY             // Center content on both axis
)

Flexibile Panel System

Declare layouts directly in source per-window that the user can or cannot modify with resizable splitters, inspired by foobar2000's ColumnsUI plugin.

HSplitter() {
    Panel("Sidebar");
    VSplitter() {
        Panel("Editor");
        Panel("Console");
    }
}

Advanced Text Rendering

Full Unicode support including CJK and greyscale emojis, multiple font styles, LCD subpixel rendering, and cached glyph atlases for performance.

TextU32(U"Hello 世界", 
    fontSize(16) 
    textColour(255, 255, 255)
    justify_center);

Current Status

SableUI is approaching v1.0. and the core features are stable, but no limited component library as of current so use cases are slim

When v1.0. is done, the following features will be fully implmented:

  • Vulkan/Metal backends
  • Tested linux & macOS support
  • Component library (scrollviews, input fields, tab stacks, sliders, etc)
  • Expanded documentation
  • Shader transpilation across multiple graphics backends

Platform Support

...

Graphics Backends

  • OpenGL 3.3+: 98% (small bug)
  • Vulkan and Metal: coming soon





Next steps:

Card image

Getting Started

Build a starter application in under 5 minutes

Card image

Core concepts

Understand concepts such as components, states and more

Card image

Examples

See real applications

Getting Started

Get SableUI runnning in under 5 minutes.

Building from source

NOTE: Building from source is required until v1.0 release

Prerequisites

  • C++20 compiler
  • CMake 3.15+
  • Git

Platform-specific requirements:

  • Linux: - Development libraries for OpenGL
  • macOS -Development libraries for OpenGL & Xcode Command Line Tools

Installation

Add to Existing CMake Project

Git Submodules only works if your-project is initialised with git, if not, you can run git init before continuing or go with option 2. Add SableUI as a submodule to your project:

cd your-project
git submodule add https://github.com/oliwilliams1/SableUI vendor/SableUI
git submodule update --init --recursive

Update your CMakeLists.txt:

# Add SableUI
add_subdirectory(vendor/SableUI)

# Link to your executable
add_executable(MyApp main.cpp)
target_link_libraries(MyApp PRIVATE SableUI)

Example CMakeLists.txt:

cmake_minimum_required(VERSION 3.15)

project(MyApp)

set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED True)

# Add SableUI
add_subdirectory(vendor/SableUI)

add_executable(MyApp main.cpp)
target_link_libraries(MyApp PRIVATE SableUI)





Now you have configured SableUI, you can now create your first application!

Building a Counter

This walks through one small, complete SableUI application, built up piece by piece. By the end it covers application setup, components, layout, styling, reactive state, interaction, composing components, and reacting to time.

Setup

Create main.cpp and include the main SableUI header, along with the (optional) style namespace:

#include <SableUI/SableUI.h>

using namespace SableUI;
using namespace SableUI::Style;

The application lifecycle

Every SableUI application follows the same shape in main():

int main()
{
	InitialisePrimaryWindow();

	while (WaitEvents())
		Render();

	Shutdown();

	return 0;
}

InitialisePrimaryWindow() creates the window and sets up the renderer. WaitEvents() handles input and returns false once the window should close, so it doubles as the loop condition. Render() draws the current frame. Shutdown() cleans everything up. This shape doesn't change regardless of what the application actually does — application logic lives elsewhere, not in this loop.

Note: WaitEvents() can be switched out with WaitEventsTimeout(double timeout) or PollEvents() based on application type. WaitEvents() is typically used for general applications that do not require updates to match the refresh rate of a display, PollEvents() loops instantanously, useful for games, and WaitEventsTimeout(double timeout) is a balance between the two, which waits for events or the specified time runs out.

Your first component

A component is a class deriving from BaseComponent that overrides Layout():

class Counter : public BaseComponent
{
public:
	void Layout() override
	{
		Text("Count: 0");
	}
};

Layout() is called during rendering and is where the component's contents are declared — including any conditional logic or loops, since it's ordinary C++. Text(...) adds a text element to the layout.

For example, you can have the following code which lays out exactly as it reads, which is a luxery some solutions don't have, and is built right into the core of SableUI.

class Counter : public BaseComponent
{
public:
	void Layout() override
	{
		for (int i = 0; i < 10; i++)
		{
			if (i % 2 == 0)
			{
				Text(SableString::Format("Element number: %d", i));
			}
		}
	}
};

But for the sake of this tutorial, we will revert back to the earlier snippet.

Displaying it

Before the main loop, register the component under a name, then add a panel that uses it:

int main()
{
	InitialisePrimaryWindow();
	RegisterComponent<Counter>("Counter");

	Panel("Counter");

	while (WaitEvents())
		Render();

	Shutdown();

	return 0;
}

RegisterComponent<T>(string key) makes Counter creatable by string elsewhere in the framework. Panel("Counter") adds a panel to the window's panel tree and attaches an instance of the registered component to it.

Layout and styling

Div(...) { ... } groups elements together and applies styling to the group. It's a scoped construct — the braces aren't decorative, they define which elements are children of that div:

void Layout() override
{
	const Theme& t = GetTheme();

	Div(bg(t.surface0), p(30), centerXY, rounded(10))
	{
		Text("Count: 0", fontSize(28), mb(20), textWrap(false));
	}
}

Each argument to Div(...) or Text(...) — bg(...), p(30), centerXY, rounded(10), fontSize(28), mb(20) — is a small style value. They can be freely mixed and chained in any order. GetTheme() returns the current theme, so colours can be pulled from it (t.surface0) rather than hardcoded, which keeps components reusable across different themes.

Making the count reactive

To make Count: 0 into a real counter, add a State<int> member:

class Counter : public BaseComponent
{
public:
	void Layout() override
	{
		const Theme& t = GetTheme();

		Div(bg(t.surface0), p(30), centerXY, rounded(10))
		{
			Text(SableString::Format("Count: %d", count.get()), fontSize(28), mb(20), textWrap(false));
		}
	}

private:
	State<int> count{ this, 0 };
};

State<T> takes the owning component (this) and a default value. Unlike a plain member variable, a State<T> survives across rerenders and reconciliation, and calling .set(...) on it automatically marks the component dirty and schedules a rerender — there's no separate step to tell the framework something changed. .get() reads the current value. SableString::Format(...) works like sprintf.

Adding interactivity

Button(label, callback, ...) wires a click to a callback, and takes the same style arguments as Div or Text:

Div(left_right, mb(8))
{
	Button("Increment", [this]() { count.set(count.get() + 1); }, mr(4));
	Button("Decrement", [this]() { count.set(count.get() - 1); });
}

left_right lays the div's children out horizontally instead of the default vertical stacking. The callback is a lambda capturing this, so it can reach back into the component's own state — clicking the button calls count.set(...), which triggers the same rerender as any other state change.

Composing components

A component can nest another component inside its Layout(). Here's a second component that just displays a string it's given:

class IntervalDisplay : public BaseComponent
{
public:
	void Layout() override
	{
		Text("Child component");
		Text(formattedTime, textWrap(false));
	}

	void SetFormattedTime(const SableString& str)
	{
		formattedTime = str;
	}

private:
	SableString formattedTime = ""; // Not state!
};

formattedTime is a plain member, not a State<T>. It doesn't need to be state because it's recomputed and handed down fresh by the parent every time Layout() runs. If it were a state, and improper state would be preserved, as it will initialise the internal state after the component scope in the parent ends.

To use it inside Counter, register it and nest it with ComponentScoped:

ComponentScoped(intervalDisplay, IntervalDisplay, this, bg(t.surface1), rounded(8), p(8))
{
	intervalDisplay->SetFormattedTime(
		SableString::Format("Seconds since application start: %d", time.get())
	);
}

ComponentScoped(name, Type, owner, <optional> styleArgs) { ... } creates a child component of the given type, gives back a pointer (intervalDisplay) usable inside the braces, and attaches it to owner once the block ends. Data flows one way here: Counter calls a setter on IntervalDisplay, and IntervalDisplay has no way to reach back into Counter's state. The optional field: styleArgs is used for applying styling to the childs containing element.

Reacting to time

OnUpdate(const UIUpdateContext&) is a separate override from Layout(), called every frame, for logic that reacts to time or input rather than declaring what's on screen. Combined with an Interval, it can drive state changes on a schedule:

class Counter : public BaseComponent
{
public:
	Counter()
	{
		interval.Start(1000);
	}

	void OnUpdate(const UIUpdateContext& ctx) override
	{
		if (interval.IsFired(ctx))
		{
			time.set(time.get() + 1);
		}
	}

private:
	Interval interval{ this };
	State<int> time{ this, 0 };
};

interval.Start(1000) schedules the interval to fire every 1000ms, starting in the constructor. interval.IsFired(ctx) checks whether it fired this frame. Because time.set(...) marks the component dirty the same way count.set(...) did earlier, updating time from OnUpdate triggers a rerender through the exact same path a button click does — there's no separate mechanism to learn for time-driven versus user-driven updates.

Putting it together

#include <SableUI/SableUI.h>

using namespace SableUI;
using namespace SableUI::Style;

class IntervalDisplay : public BaseComponent
{
public:
	void Layout() override
	{
		Text("Child component");
		Text(formattedTime, textWrap(false));
	}

	void SetFormattedTime(const SableString& str)
	{
		formattedTime = str;
	}

private:
	SableString formattedTime = ""; // Not state!
};

class Counter : public BaseComponent
{
public:
	Counter()
	{
		interval.Start(1000);
	}

	void Layout() override
	{
		const Theme& t = GetTheme();

		Div(bg(t.surface0), p(30), centerXY, rounded(10))
		{
			Text(SableString::Format("Count: %d", count.get()), fontSize(28), mb(20), textWrap(false));

			Div(left_right, mb(8))
			{
				Button("Increment", [this]() { count.set(count.get() + 1); }, mr(4));
				Button("Decrement", [this]() { count.set(count.get() - 1); });
			}
			ComponentScoped(intervalDisplay, IntervalDisplay, this, bg(t.surface1), rounded(8), p(8))
			{
				intervalDisplay->SetFormattedTime(
					SableString::Format("Seconds since application start: %d", time.get())
				);
			}
		}
	}

	void OnUpdate(const UIUpdateContext& ctx) override
	{
		if (interval.IsFired(ctx))
		{
			time.set(time.get() + 1);
		}
	}

private:
	State<int> count{ this, 0 };

	Interval interval{ this };
	State<int> time{ this, 0 };
};

int main()
{
	InitialisePrimaryWindow();
	RegisterComponent<Counter>("Counter");
	RegisterComponent<IntervalDisplay>("IntervalDisplay");

	Panel("Counter");

	while (WaitEvents())
		Render();

	Shutdown();

	return 0;
}

Styling Guide

SableUI uses a Tailwind-inspired styling system with chainable modifiers. Styles are applied inline where elements are declared, eliminating the need for verbose styling or separate stylesheet files.

Philosophy

Traditional UI frameworks separate structure and style, but SableUI brings styles directly to your component definition, just like the powerful Tailwind + React stack, without the overhead.

Example:

Div(bg(90, 160, 255), p(12), rounded(8), w(120))
{
    Text("Click me", textColour(255, 255, 255), fontSize(16));
}

Core Concepts

Div(...), Text(...), and the other element macros are just function calls, so every modifier inside them is a comma-separated argument, not whitespace-separated syntax — Div(w(200), h(100)) is ordinary C++, not a special parser.

Each modifier (like w(200)) is a small, constexpr property object holding a value and a function pointer that writes it into the right field of an ElementInfo:

Div(w(200), h(100), bg(45, 45, 45), p(10), m(5), rounded(8))
  • w(200) writes 200 into ElementInfo::layout.width
  • bg(45, 45, 45) writes Colour(45, 45, 45) into ElementInfo::appearance.bg

PackStyles folds however many of these are passed into a single ElementInfo at compile time via variadic templates, which is why modifiers can be given in any order and there's no runtime cost to the chaining.

You can find a list of the modifiers and definitions in SableUI/styles/styles.h, or in this table here.

Spacing Units

All spacing values are in pixels. There are no relative units (%) currently and isn't planned anytime soon.

Div(w(300), h(200))  // 300px wide, 200px tall

Layout Properties

Width & Height

// Fixed dimensions
Div(w(200), h(100))

// Fill available space (shared evenly across other 'fill' siblings)
Div(w_fill, h_fill)

// Fit content (default for most elements)
Div(w_fit, h_fit)

// Constraints
Div(minW(100), maxW(500), minH(50), maxH(300))

Width/Height Types:

  • w(value)/h(value) - Fixed size in pixels
  • w_fill/h_fill - Fill available space
  • w_fit/h_fit - Fit to content size
  • minW/maxW/minH/maxH - Size constraints in pixels

Example:

Div(w_fill, h_fit, minH(200), maxH(600))
{
    // Takes full width, height fits content
    // but constrained between 200-600px
}

Margin & Padding

// All sides
Div(m(10), p(20))

// Horizontal/Vertical
Div(mx(15), my(10), px(20), py(15))

// Individual sides
Div(mt(5), mr(10), mb(5), ml(10))
Div(pt(5), pr(10), pb(5), pl(10))

Example:

Div(bg(200, 200, 200), m(20), p(15))
{
    // 20px margin (transparent space outside)
    // 15px padding (gray space inside, before content)
    Text("Content");
}

Borders

Border width is separate from corner radius (see Border Radius below) — a bordered element needs both a width and a colour to actually show anything.

// All sides
Div(b(2), borderColour(80, 80, 80))

// Horizontal/Vertical
Div(bx(2), by(1), borderColour(80, 80, 80))

// Individual sides
Div(bt(1), bb(1), borderColour(80, 80, 80))

Example:

Div(b(1), borderColour(60, 60, 60), rounded(8), bg(35, 35, 35))
{
    Text("Bordered panel");
}

Layout Direction

Controls how child elements flow

// Vertical (default)
Div(up_down)
{      // Top to bottom
    Text("First");
    Text("Second");
}

Div(down_up)
{      // Bottom to top
    Text("First");
    Text("Second");
}

// Horizontal
Div(left_right)
{   // Left to right
    Text("First");
    Text("Second");
}

Div(right_left)
{   // Right to left
    Text("First");
    Text("Second");
}

Default: up_down (top to bottom)

Centering

// Center horizontally
Div(centerX)
{
    Text("Centered");
}

// Center vertically
Div(centerY)
{
    Text("Centered");
}

// Center both axes
Div(centerXY)
{
    Text("Centered");
}

Note: Centering applies to the element within its parent, not to the element's children.

Example

Div(w(500), h(500), bg(255, 0, 0))
{
     // Rect is centered within its parent
    RectElement(w(50), h(50), centerXY, bg(0, 255, 0))
}

Overflow

By default, children that are larger than their parent will overflow it visibly. clipChildren (alias overflow_hidden) clips them to the parent's bounds instead:

Div(w(200), h(100), clipChildren)
{
    // Anything drawn outside this 200x100 box gets clipped
}

Colours

The bg (background colour) modifier can use RGB or RGBA values:
Colour Format: bg(r, g, b, a) where:

  • r,g,b - Red, Green, Blue (0-255)
  • a - Alpha/opacity (0-255, default 255 (opaque))
// Background color
Div(bg(45, 45, 45))           // RGB
Div(bg(45, 45, 45, 200))      // RGBA (with alpha)

The same rules go with the textColour property:

// Text color
Text("Hello", textColour(255, 255, 255))
Text("Faded", textColour(200, 200, 200, 128))

And you can use the rgb/rgba modifier for added flexibility.
Example with inline conditional colours:

// Using rgb/rgba helpers
Div(bg(value == true ? rgb(255, 45, 45) : rgb(45, 255, 45)))
Div(bg(rgba(45, 45, 45, 200)))

bg colours are opaque by default; use inheritBg(true) if you'd rather an element take on its parent's background than set its own. See Theming for how GetTheme() colours like t.surface0 fit into all of this — most real components pull from the active theme rather than hardcoding RGB values.

Border Radius

// All corners
Div(rounded(8))

// Sharp corners
Div(rounded(0))

// Pill shape
Div(w(100), h(40), rounded(20))

Individual corners can be set separately with roundedTL, roundedTR, roundedBL, roundedBR, or a whole edge at once with roundedTop, roundedBottom, roundedLeft, roundedRight:

// Rounded only on top, sharp on bottom — e.g. a dropdown panel
Div(roundedTop(8), roundedBottom(0))

Text Properties

Font Size

Text("Small", fontSize(10))
Text("Normal", fontSize(14))
Text("Large", fontSize(24))
Text("Huge", fontSize(48))

Default: 11px

Line Height

Controls spacing between lines of wrapped text:

Text("Multi-line text that will wrap...",
    fontSize(14),
    lineHeight(1.5)  // 1.5x the font size
)

Default: 1.15 (15% taller than font size)

Text Justification

Text("Left aligned", justify_left)
Text("Centered", justify_center)
Text("Right aligned", justify_right)

Default: justify_left

Text Wrapping

// Wrap text (default)
Text("Long text that will wrap...", maxW(200), textWrap(true))

// No wrapping (minimum size is constrained to one line)
Text("Long text that won't wrap...", maxW(200), textWrap(false))

Text Styling

Use string methods for bold, italic, etc.

Learn more about how SableString functions and how why/how .bold() is implmented/required here.

Example with formatting:

SableString message = 
    SableString("This is ") + 
    SableString("super cool").bold() + 
    SableString(" formatting").italic();
TextU32(message);

Output: This is super cool formatting

This will probably be improved with macros

Absolute Positioning

Div(absolutePos(100, 50))
{
    Text("At x:100, y:50");
}

[!WARNING] Absolute positioning can mess up the layout tree significantly. For most use cases that require absolute positioning, it is recomended you use them with a CustomTargetQueue which is seperate to the default element tree. You can find out more about it in Advanced Topics and can view documented implmentations of specific use cases like that require absolute positioning and custom render targets like modals.

Identification

Any element or component macro accepts id(...), which tags it for lookup later with GetElementById:

Div(id("SubmitButton"), bg(t.primary))
{
    Text("Submit");
}

This is mostly useful from inside a component's own OnUpdate/OnUpdatePostLayout, for hit-testing against an element it just laid out — see Event Handling and Components for examples.

Component Sizing

size_sm, size_md, size_lg, and size_none don't affect plain elements — they're read by SableUI's built-in components (Button, Checkbox, etc.) to scale their own internal padding and font size. disabled(bool) similarly only has an effect on components that check info.appearance.disabled themselves.

Button("Small button", onClick, size_sm)
Button("Icon button", onClick, size_none, w(24), h(24))  // fully custom sizing

See Components for what each component does with these.



Card image

Style Reference

Grid of definitions for style macros

Card image

Theming

Where GetTheme() and its colours come from

Card image

Event Handling

Add interactivity with onClick, onHover, etc.

State Management

Every SableUI component can rebuild its Layout() at any point, so any value that needs to survive that rebuild — and any value that should cause a rebuild when it changes — has to be declared as one of SableUI's state types rather than a plain member variable. There are four of them: State<T>, Ref<T>, Timer, and Interval.

All four are declared as members and constructed with the owning component as their first argument, which registers them so the framework can carry their value across reconciliation:

class MyComponent : public BaseComponent
{
private:
    State<int> count{ this, 0 };
};

A plain member variable (no state wrapper) is still completely valid — it's the right choice for anything recomputed fresh every Layout() call, such as a value handed down from a parent. See the formattedTime member in Your First Application for that pattern. State types exist for the values that don't fit that description.

State<T>

State<T> is the one you'll reach for by default — a value the component owns, that persists across rerenders, and that triggers a rerender when it changes.

State<int> count{ this, 0 };

Read it with .get(), and change it with .set(...):

Text(SableString::Format("Count: %d", count.get()));
// ...
count.set(count.get() + 1);

.set(...) checks the new value against the current one first — if they're equal, nothing happens; if they differ, the value is updated and the owning component is marked dirty for a rerender. This means calling .set() with an unchanged value is cheap and safe to do unconditionally, and it's also why T has to support operator== — State<T> won't compile for a type that can't be compared.

State<T> also supports assignment and implicit conversion, so count = count + 1; and int c = count; both work, though .get()/.set() are the clearer choice in most code.

Ref<T>

Ref<T> looks almost identical to State<T> — same constructor shape, same registration with the owner — but .set(...) does not mark the component dirty. It exists for values that need to survive across rerenders (so they can't just be a plain member) but shouldn't themselves cause a rerender when they change.

The clearest real example is a stored callback. Checkbox keeps the caller's onChange handler in a Ref, not a State:

Ref<std::function<void(bool)>> onChangeCallback{ this, nullptr };

Wrapping that in State<T> instead would be actively wrong here — std::function doesn't have operator==, so it wouldn't compile, and even if it did, reassigning a callback is not something that should trigger a visual rerender on its own.

As a rule: if changing the value should update what's on screen, use State<T>. If it just needs to persist and doesn't affect layout directly, use Ref<T>.

Timer and Interval

Both Timer and Interval hook into the background event scheduler rather than driving a value directly — they're for scheduling when something happens, and you still update a State<T> yourself once it does.

Timer fires once, after a delay:

Timer saveDelay{ this };
// ...
saveDelay.Start(2000); // fires once, 2000ms from now

Interval fires repeatedly, on a fixed period:

Interval tick{ this };
// ...
tick.Start(1000); // fires every 1000ms

Both are checked from OnUpdate(). Interval has a convenience method for this:

void OnUpdate(const UIUpdateContext& ctx) override
{
    if (tick.IsFired(ctx))
    {
        seconds.set(seconds.get() + 1);
    }
}

Timer doesn't expose its own IsFired, so check it against the input context directly using its handle — this form also works for Interval, and is exactly what Interval::IsFired(ctx) does internally:

void OnUpdate(const UIUpdateContext& ctx) override
{
    if (ctx.input.IsFired(saveDelay.GetHandle()))
    {
        DoSave();
    }
}

Both also expose .Stop() and .Reset() — Reset() restarts the countdown/period from now without needing to call .Start() again with the same duration, which is how TextFieldComponent keeps its cursor blinking on a steady rhythm restarted every time a key is pressed, rather than drifting.





Event Handling

Add interactivity with onClick, OnUpdate, and input state

Components

Button, Checkbox, TextField, DatePicker, and Calendar

Examples

See state management in real applications

Style Modifiers Reference

Sizing

MacroDescriptionExample
w(n)Fixed widthw(200)
h(n)Fixed heighth(100)
w_fillFill parent widthw_fill
h_fillFill parent heighth_fill
w_fitFit content widthw_fit
h_fitFit content heighth_fit
minW(n)Minimum widthminW(100)
maxW(n)Maximum widthmaxW(500)
minH(n)Minimum heightminH(50)
maxH(n)Maximum heightmaxH(300)

Further reference for sizing styling

Spacing

MacroDescriptionExample
m(n)Margin (all sides)m(10)
mx(n)Margin horizontalmx(15)
my(n)Margin verticalmy(10)
mt/mr/mb/ml(n)Individual marginsmt(5)
p(n)Padding (all sides)p(20)
px(n)Padding horizontalpx(15)
py(n)Padding verticalpy(10)
pt/pr/pb/pl(n)Individual paddingpt(5)

Further reference for margin and padding styling

Borders

MacroDescriptionExample
b(n)Border width (all sides)b(2)
bx(n)Border width horizontalbx(2)
by(n)Border width verticalby(2)
bt/bb/bl/br(n)Individual border widthsbt(1)
borderColour(r,g,b)Border colourborderColour(80,80,80)
borderColour(r,g,b,a)Border colour with alphaborderColour(80,80,80,180)

This is border width — for corner radius, see Visual below.

Further reference for border styling

Colors

MacroDescriptionExample
bg(r,g,b)Background colorbg(45,45,45)
bg(r,g,b,a)Background with alphabg(45,45,45,200)
textColour(r,g,b)Text colortextColour(255,255,255)
rgb(r,g,b)Color helperbg(rgb(45,45,45))
rgba(r,g,b,a)RGBA helperbg(rgba(45,45,45,200))
inheritBg(bool)Inherit background from parent instead of the theme defaultinheritBg(false)

Further reference for colour styling, and Theming for where these colours can come from

Layout

MacroDescriptionExample
up_downChildren top→bottomup_down
down_upChildren bottom→topdown_up
left_rightChildren left→rightleft_right
right_leftChildren right→leftright_left
centerXCenter horizontallycenterX
centerYCenter verticallycenterY
centerXYCenter both axescenterXY
clipChildrenClip children that overflow this element (alias: overflow_hidden)clipChildren

Further reference for layout directions and centering

Text

MacroDescriptionExample
fontSize(n)Font size in pixelsfontSize(16)
lineHeight(n)Line height multiplierlineHeight(1.5)
justify_leftAlign text leftjustify_left
justify_centerCenter textjustify_center
justify_rightAlign text rightjustify_right
textWrap(bool)Enable/disable wrappingtextWrap(false)

Further reference for text-based properties

Visual

MacroDescriptionExample
rounded(n)Border radius, all cornersrounded(8)
roundedTL/TR/BL/BR(n)Radius on a single cornerroundedTL(8)
roundedTop/Bottom/Left/Right(n)Radius on both corners of one edgeroundedTop(8)
absolutePos(x,y)Absolute positionabsolutePos(100,50)

[!WARNING] Absolute positioning can mess up the layout tree significantly. For most use cases that require absolute positioning, it is recomended you use them with a CustomTargetQueue which is seperate to the default element tree. You can find out more about it in Advanced Topics and can view documented implmentations of specific use cases like that require absolute positioning and custom render targets like modals.

Identification

MacroDescriptionExample
id(str)Assign an id to an element, for lookup later via GetElementByIdid("Submit")

Component Sizing

These only affect SableUI's built-in components (Button, Checkbox, etc.) — they have no effect on a plain Div, Text, or Rect.

MacroDescriptionExample
size_smSmall component sizingsize_sm
size_mdMedium component sizing (default)size_md
size_lgLarge component sizingsize_lg
size_noneDisable automatic sizing/padding entirelysize_none
disabled(bool)Disable a componentdisabled(true)

Further reference for how each built-in component uses these





Card image

Styling Guide

In-depth guide for all style modifiers

Card image

Theming

Theme, ThemeManager, and where bg/textColour defaults come from

Card image

Components

Button, Checkbox, TextField, DatePicker, and Calendar

Event Guide

SableUI provides a safe event system that allows you to create interactive components with mouse, keyboard, and scroll input. Simple element events can be attached directly in the Layout() phase using inline callbacks; anything more involved — held keys, drag state, hit-testing — goes through the OnUpdate() method instead.

Inline Events

SableUI provides a small set of callbacks that attach directly to an element, for the common cases where you just need "something happened to this specific element."

Everything else — scrolling, all keyboard events, held/dragged state, and hover — is read from the UIUpdateContext in OnUpdate(), covered below.

onClick

Triggered when the left mouse button is clicked on an element.

Div(onClick([this]() {
    count.set(count.get() + 1);
    SableUI_Log("Clicked! Count: %d", count.get());
}))
{
    Text(SableString::Format("Click me, num clicks: %d", count.get()),
        textColour(255, 255, 255));
}

onSecondaryClick

Triggered when the right mouse button is clicked on an element.

Div(onSecondaryClick([this]() {
    SableUI_Info("Right clicked");
}))
{
    Text("Right-click me", textColour(200, 200, 200));
}

Commonly paired with a context menu shown as a floating panel positioned at the click location.

onDoubleClick

Triggered when an element is clicked twice within a short window.

Note: The double-click timing window is 300ms, and clicks must be within 5 pixels of each other to register as a double-click. These thresholds are constants on the Window class in window.h — they aren't currently exposed as something an application can configure per-instance.

[!WARNING] State lambdas can be dangerous and cause problems if used incorrectly — reference-capturing lambdas ([&]) can be unstable if the referenced variable goes out of scope before the callback fires. The best practice is to capture this and other arguments by value, for example: onClick([this, otherVar1, otherVar2]() {});.

Hover

There's no onHover/onHoverExit inline callback yet — ElementInfo doesn't carry hover callbacks the way it does onClickFunc. Until that lands, hover has to be computed manually in OnUpdate() by hit-testing the mouse position against an element's rect, the same way ButtonComponent tracks its own pressed state internally:

void OnUpdate(const UIUpdateContext& ctx) override
{
    Element* root = GetRootElement();
    if (!root) return;

    bool hovered = RectBoundingBox(root->rect, ctx.input.mousePos, ctx.input.obscurers, ctx.zIndex);
    isHovered.set(hovered);
}

Keyboard Input

Keyboard events are global rather than element-specific, so there's no onKeyPress(...) element callback — instead, override OnUpdate() and read key state off the input context directly. If a keyboard shortcut should only fire while the component is hovered, pair it with the same RectBoundingBox hit-test shown above.

Accessing the Event Context

Override OnUpdate() in your component to reach keyboard, mouse, and timer state:

class MyComponent : public SableUI::BaseComponent {
public:
    void Layout() override {
        // Your UI layout here
    }

    void OnUpdate(const UIUpdateContext& ctx) override {
        // ctx.input is the UIInputState for this frame
        // ctx.zIndex is this component's current z-index
    }
};

Key Constants

SableUI provides constexpr constants for all keyboard keys, borrowed from GLFW (the window manager) for easy translation. These constants follow the pattern SABLE_KEY_*:

A list of these keys can be grabbed from events.h

Key State Queries

ctx.input provides three ways to query key states, each a std::bitset<SABLE_MAX_KEYS>:

isKeyDown

true every frame while the key is held down.

void OnUpdate(const UIUpdateContext& ctx) override {
    if (ctx.input.isKeyDown.test(SABLE_KEY_W))
    {
        // Move forward continuously
        posY.set(posY.get() - speed * ctx.input.deltaTime);
    }

    if (ctx.input.isKeyDown.test(SABLE_KEY_S))
    {
        // Move backward continuously
        posY.set(posY.get() + speed * ctx.input.deltaTime);
    }
}

keyPressedEvent

true only on the frame a key is pressed. Use for single actions.

void OnUpdate(const UIUpdateContext& ctx) override {
    if (ctx.input.keyPressedEvent.test(SABLE_KEY_SPACE))
    {
        // Toggle state once per press
        isPaused.set(!isPaused.get());
    }
}

keyReleasedEvent

true only on the frame a key is released.

void OnUpdate(const UIUpdateContext& ctx) override {
    if (ctx.input.keyReleasedEvent.test(SABLE_KEY_LEFT_SHIFT))
    {
        // Stop running when shift is released
        isRunning.set(false);
    }
}

Modifier Keys

These tests can be combined to build key combination events.

void OnUpdate(const UIUpdateContext& ctx) override {
    bool ctrlPressed = ctx.input.isKeyDown.test(SABLE_KEY_LEFT_CONTROL) ||
                        ctx.input.isKeyDown.test(SABLE_KEY_RIGHT_CONTROL);

    // Ctrl+S for save
    if (ctrlPressed && ctx.input.keyPressedEvent.test(SABLE_KEY_S))
    {
        Save();
    }
}

Text input itself — actual typed characters, as opposed to individual key presses — comes through ctx.input.typedCharBuffer, a std::vector<unsigned int> of codepoints typed this frame. See TextFieldComponent in Components for a full example handling typed input, selection, and clipboard together.

Mouse Position and Scrolling

ctx.input also carries mouse position and scroll information:

Mouse Position

void OnUpdate(const UIUpdateContext& ctx) override {
    int mouseX = ctx.input.mousePos.x;
    int mouseY = ctx.input.mousePos.y;

    // Mouse delta since last frame
    int deltaX = ctx.input.mouseDelta.x;
    int deltaY = ctx.input.mouseDelta.y;

    SableUI_Log("Mouse pos: %dx%d, mouse delta: %dx%d",
        mouseX, mouseY, deltaX, deltaY);
}

Scrolling

void OnUpdate(const UIUpdateContext& ctx) override {
    float scrollX = ctx.input.scrollDelta.x;
    float scrollY = ctx.input.scrollDelta.y;

    if (scrollY != 0.0f)
    {
        // Zoom in/out based on scroll
        zoomLevel.set(zoomLevel.get() + scrollY * 0.1f);
    }
}

Mouse Button State

Mouse buttons are queried the same way as keyboard keys, against SABLE_MOUSE_BUTTON_* constants:

void OnUpdate(const UIUpdateContext& ctx) override {
    // Check if left mouse button is held down
    if (ctx.input.mouseDown.test(SABLE_MOUSE_BUTTON_LEFT))
    {
        // Drag operation
        dragX.set(dragX.get() + ctx.input.mouseDelta.x);
        dragY.set(dragY.get() + ctx.input.mouseDelta.y);
    }

    // Check for mouse button press
    if (ctx.input.mousePressed.test(SABLE_MOUSE_BUTTON_LEFT))
    {
        isDragging.set(true);
    }

    // Check for mouse button release
    if (ctx.input.mouseReleased.test(SABLE_MOUSE_BUTTON_LEFT))
    {
        isDragging.set(false);
    }
}

There's also mouseDoubleClicked, tested the same way, if onDoubleClick on a specific element isn't granular enough for what you need.

Delta Time

ctx.input.deltaTime gives frame-independent timing for animation and movement:

void OnUpdate(const UIUpdateContext& ctx) override {
    if (ctx.input.isKeyDown.test(SABLE_KEY_RIGHT))
    {
        // Move at constant speed regardless of frame rate
        posX.set(posX.get() + speed * ctx.input.deltaTime);
    }
}

deltaTime is in seconds, so if speed = 100.0f, the object moves at 100 pixels per second.

Timers

For anything on a schedule rather than tied to a specific input — a blinking cursor, a polling interval, a delayed action — use Timer or Interval instead of checking deltaTime by hand. See State Management for how those work and how they interact with OnUpdate.





State Management

State<T>, Ref<T>, Timer, and Interval for reactive components

Components

Button, Checkbox, TextField, DatePicker, and Calendar

Examples

See event handling in real applications

Theming

Most of SableUI's built-in components, and most of the styling examples elsewhere in these docs, pull their colours from the active theme rather than hardcoding RGB values:

const Theme& t = GetTheme();

Div(bg(t.surface0), rounded(10))
{
    Text("Themed panel", textColour(t.text));
}

GetTheme() is a free function that returns the currently active Theme — a plain struct of Colour fields, so t.surface0 is just a colour value like any you'd pass to bg(...) directly.

Theme Structure

A Theme is organised into a few groups:

Background layers — base, mantle, crust — for the window background and progressively darker/recessed panels.

Surfaces — surface0, surface1, surface2 — for elements that sit visually above the background, each more "elevated" than the last.

Overlays — overlay0, overlay1, overlay2 — for borders, dividers, and other low-emphasis chrome, darkest to lightest.

Text — subtext0 and subtext1 for muted/secondary text, text for primary text — plus subtext0Contrast, subtext1Contrast, and textContrast, the same three but chosen to stay legible on top of an accent colour rather than the theme's background.

Accent palette — rosewater, flamingo, pink, mauve, red, maroon, peach, yellow, green, teal, sky, sapphire, blue, lavender. If those names look familiar, they're the same set used by the Catppuccin colour scheme.

Semantic colours — primary, secondary, error, warning, success, info, checkColour — the ones most component code actually reads (ButtonComponent uses t.primary for its default background, for instance). If a theme doesn't set these explicitly, they fall back to a colour from the accent palette (primary→blue, error→red, warning→yellow, success→green, info→sky, checkColour→lavender, secondary→rosewater) the first time InitialiseSemantics() runs. Both built-in themes override most of these with their own values anyway, so treat the fallback as a safety net for custom themes rather than something to rely on.

Built-in Themes

SableUI registers two themes automatically: "sableui_dark" (the default active theme) and "sableui_light". Switch between them with:

ThemeManager::GetInstance().SetActiveTheme("sableui_light");

SetActiveTheme returns false and logs an error if the name isn't registered, rather than silently doing nothing.

Registering a Custom Theme

A full theme is just a Theme struct populated field by field, registered under a name:

Theme myTheme;
myTheme.name = "my_theme";
myTheme.base = Colour{ 18, 18, 20, 255 };
// ... fill in the rest ...
myTheme.InitialiseSemantics(); // fill any unset semantic colours from the palette

ThemeManager::GetInstance().RegisterTheme("my_theme", myTheme);

Theme Variants

If you only want to tweak a few colours from an existing theme rather than define a whole new one, ThemeOverride lets you register a variant instead. Every field is std::optional, and only the ones you set are applied on top of the base theme:

ThemeOverride highContrast;
highContrast.text = Colour{ 255, 255, 255, 255 };
highContrast.overlay1 = Colour{ 140, 140, 140, 255 };

ThemeManager::GetInstance().RegisterThemeVariant(
    "sableui_dark_high_contrast", "sableui_dark", highContrast
);

This resolves to a full Theme at registration time (ThemeOverride::Apply), so switching to the variant afterwards is exactly as cheap as switching to any other theme.





Styling Guide

bg, textColour, and the rest of the style macros

Components

Where t.primary, t.surface0, and friends actually get used

Examples

See custom themes in real applications

Custom Render Targets

Components

SableUI ships a small component library on top of the core framework — enough to build the MVP without hand-rolling every button and text field. Each one is a normal BaseComponent under the hood, added via a macro that handles registering it as a scoped child and calling its Init(...), the same pattern Button(...) uses in Your First Application.

All of them read from the active theme (see Theming) and respond to Component Sizing (size_sm/size_md/size_lg/size_none, disabled(...)) unless noted otherwise.

Button

Button(label, callback, ...)
Button("Save", [this]() { Save(); }, size_lg);

label accepts a SableString, so Unicode and emoji labels work directly — useful for icon-only buttons:

Button(U"\U0001F4C5", [this]() { ToggleCalendar(); },
    w(16), h(16), fontSize(8), size_none, bg(rgba(0, 0, 0, 0)), rounded(999));

By default a button's background is t.primary, its corners are rounded 4px unless overridden, and its padding scales with size_sm/size_md/size_lg (or comes from your own p/px/py if you set one). disabled(true) both dims the button to t.subtext0 and stops the callback from firing on click, so it's safe to leave a click handler wired up on a button that's conditionally disabled.

The button tracks its own pressed state internally (a State<bool> set from hit-testing in OnUpdate) purely to darken its background slightly while held — that state isn't exposed, so if you need to know whether a button is currently pressed from outside it, you'll need to track that yourself in the callback.

Checkbox

Two macros, depending on who owns the boolean:

// Externally-owned State<bool> — auto-syncs both ways
CheckboxState(label, checkedState, ...)

// Internally-owned bool with a callback
Checkbox(label, checked, onChange, ...)
State<bool> agreed{ this, false };
// ...
CheckboxState("I agree to the terms", agreed);
Checkbox("Enable notifications", notificationsEnabled,
    [this](bool v) { notificationsEnabled = v; });

Box size and label font size follow the same small/medium/large scale as Button (12px/15px/18px box, 10pt/11pt/13pt label). disabled(...) dims the checked colour rather than blocking the click outright — the click handler still checks info.appearance.disabled itself and no-ops, so a disabled checkbox won't toggle even though it's visually similar to an inactive button.

TextField / InputField

InputField(state, ...)  // single line
TextField(state, ...)   // multiline

Both take a State<InputFieldData>&, where InputFieldData holds the field's content, placeholder, focus state, and optional onChange/onSubmit callbacks:

State<InputFieldData> name{ this, { .placeholder = "Your name" } };
// ...
InputField(name);

Typing, backspace/delete, arrow-key cursor movement with shift-to-select, and clipboard cut/copy/paste are all implemented already, and onSubmit fires on Enter for single-line fields (multiline inserts a newline instead). Clicking outside the field unfocuses it; Escape clears an active selection first, then unfocuses on a second press.

[!WARNING] The visible text cursor and selection highlight aren't drawn yet — the underlying state (cursorPos, cursorVisible, a blinking Interval) is all tracked correctly, but the actual draw call in OnUpdatePostLayout is still commented out pending a custom render target. Typing and selecting both work; you just can't currently see the caret while doing it.

If you're building a component around a text field that needs extra content alongside it — an icon, a button, a suffix — override ContentLeft()/ContentRight() rather than reimplementing the field. This is how DatePickerComponent adds its calendar button without touching any of the text-editing logic.

DatePicker

DateField(state, ...)

DatePickerComponent is a TextFieldComponent subclass — it displays a formatted date and syncs it from an internal CalendarContext, and adds a calendar-icon button via ContentRight():

State<InputFieldData> deadline{ this, {} };
// ...
DateField(deadline);

[!WARNING] The calendar icon button toggles the underlying "open" state correctly, but the floating panel that's meant to actually show the popup calendar (CalendarHelperPostLayout) is currently commented out, so clicking it doesn't yet show anything on screen. It also doesn't currently restrict typed input the way a finished date picker would — since it inherits full text editing from TextFieldComponent, typing directly into the field is still possible even though the intended flow is picking a date from the calendar.

If you need a working calendar today rather than the popup integration, use Calendar directly (below) — it's the piece that's actually finished, the popup wiring around it is what's still in progress.

Calendar

Calendar can be embedded directly as an inline date picker, independent of DatePicker:

State<CalendarContext> ctx{ this, {} };
// ...
ComponentScoped(calendar, Calendar, this)
{
    calendar->Init(ctx);
}

It initialises itself to today's date the first time it's laid out if ctx hasn't been set yet, renders a month header with prev/next navigation, and highlights the selected day. Reading the selection back out is just ctx.get().selectedDay / .selectedMonth / .selectedYear.

A few free functions handle the parts that don't need a live Calendar instance on screen — useful if you're driving the same CalendarContext from elsewhere in your UI:

InitCalendarToToday(ctx);
InitCalendarToDate(ctx, 2026, 5, 1); // month is 0-indexed





State Management

State<T>, Ref<T>, Timer, and Interval

Theming

Where t.primary, t.surface0, and friends come from

Event Handling

onClick, OnUpdate, and reading input state

Roadmap to 1.0

Components

  • Scroll view
  • Tab stack with component exposure/initialisation callback
  • Button
  • Sliders
  • Input field
  • Large text field
  • Input field click for cursor
  • Input field multi-line highlighting
  • Input field copy/paste support
  • Modal
  • Checkbox
  • Progress
  • Dropdown
  • Listbox
  • Keyboard chip
  • Context menu
  • Link?
  • Popover
  • Radio
  • Spinner?
  • Switch
  • Table (contents grid with sortable columns for complex)
  • Toast
  • Tooltip
  • List
  • Splitter element (horizontal & vertical)
  • Text splitter element
  • Menu bar

Bug fixes

  • Draw window border
  • Fix OpenGL context problems
  • Triple check refresh things?
  • Better/dynamic frame limiting - check "Event processing" in glfw website
  • Cannot access root element directly in TabWithInitialiser (root=nullptr in Layout())
  • Overdraw with scrollview
  • Child component state losses
  • Scroll bar doesn't update on init / resize
  • Floating panel with transparent backgrounds blitted multiple times

Features

  • Remove element tree from custom layout targets
  • Make another api for floating components
  • Add Prop

Graphics API

  • Abstract shaders
  • Abstract uniforms
  • Abstract drawables
  • Shaderc transpilation
  • Vulkan & Metal support

Events

  • Test keyboard events
  • Test scroll events

Async stuff

  • Timeouts
  • Timers
  • Animations

Styling

  • Themes
  • Text wrap property
  • Inline text colour
  • Replace macro styling
  • Inline hover styling
  • Size and disabled style
  • Gap property
  • Change texts from subpixel to greyscale for no fringing

Panels

  • Panel builder mode for user building layouts
  • Panel editor for locking / unlocking panels at runtime
  • Saving panel state across runs

QOL

  • Expose titlebar content api
  • Expose icon setting + program to embed api
  • Allow custom fonts
  • Image lazy-loading
  • Image load-by-buffer
  • Props
  • Click and drag scroll bar thumb

Beyond 1.0

  • Docking panels
  • Web support?
  • Mobile support?
  • Touch support
  • Expose graphics & shader api further for cross-platform custom shaders and objects
  • Video player
  • Simple 3d renderer
  • Plugin support
  • External layout script + basic language