Beginner to C?
Never written C? Start here.
Six steps: the toolchain, a window that does nothing, the three lines of CMake behind it, a desktop build, a web build from the same source, and then an app that does something. If your machine is already set up, the first window takes about a minute.
There is no toolchain to learn and no package manager to adopt. RayClay is one header and one link line; you install a C compiler and CMake, put the library in your build with add_subdirectory, and that is the whole of it. If you have written JavaScript, the thing to unlearn is that a UI project needs a dependency tree. This one has none.
Step zero
Get a compiler and CMake.
Two things, on any of the three platforms: something that compiles C, and CMake to drive it. Pick your column. Each command is the ordinary package-manager route for that operating system, and none of it is specific to RayClay.
# Debian or Ubuntu
sudo apt install build-essential \
cmake git
# X11 and Wayland headers
sudo apt install libwayland-dev \
libxkbcommon-dev xorg-dev
# Apple's own C compiler
xcode-select --install
# CMake, through Homebrew
brew install cmake
winget install Kitware.CMake
winget install Microsoft.VisualStudio.2022.BuildTools
On Linux the second command is the one people miss: the vendored GLFW builds against the X11 and Wayland development headers, and on a distribution other than Debian or Ubuntu you want that distribution's equivalents. macOS and Windows need only the compiler toolchain; on Windows the Build Tools installer asks which workloads to add, and the one to add is Desktop development with C++. Both Windows toolchains work - MSVC, and the GCC family: MinGW-w64, MSYS2, clang in GNU mode - and CMake needs nothing extra on either.
Check your CMake with cmake --version: you need 3.21 or newer. Step two adds RayClay with add_subdirectory, and RayClay's own build asks for 3.21, so that is the number your machine has to clear whatever your own project declares. From C, RayClay is C99. From C++ it is C++20 or nothing: the vendored layout engine refuses anything older with #error "Clay requires C99, C++20, or MSVC", and it does so for a declarations-only include - so a C++17 project fails at the #include, not at the link, and the message names a dependency you had no reason to know about. MSVC is exempt by that same condition.
Step four adds the Emscripten SDK for the web build. Nothing before it does.
Step one
The whole app, in two lines.
This is a complete program. It compiles, opens a window, runs the frame loop, and cleans up after itself.
#include "rayclay.h"
int main(void) { return rcRunApp(NULL); }
Everything that happens before your first element is rcRunApp: it creates the window and its GL context, bakes the font, drives the frame loop, and tears all of it down when the window closes. It is the one-call path, and the one this page teaches. Every later step passes an options struct where that NULL sits, so nothing you write now needs rewriting when you outgrow it; and if you eventually want to drive the frame loop yourself, rcAppCreate, rcRunFrame and rcAppDestroy are public, with rcRunApp written in terms of them.
What is on screen before you have declared anything: a centred “Welcome to RayClay” over a muted “This is your blank canvas.”, on the RayClay-dark background. Text renders because the bundled Latin-1 Roboto subset is compiled into the library, so there are no asset files to ship. On the desktop the window already zooms like a browser - Ctrl with + and - walks Chrome’s own ladder of stops, Ctrl 0 returns to 100%, Ctrl with the wheel is continuous. And rendering is on demand by default: the window draws when something actually happens, and otherwise sleeps in the OS event loop.
It also writes one line to stderr, and that line is not a mistake on your part.
RAYCLAY[WARNING]: rcRunApp: no layout callback supplied - rendering the built-in welcome canvas.
Set RC_AppOptions.layoutCallback to draw your own UI.
.layoutCallback.Because this program contains none of your code, it also separates a broken toolchain from a broken layout: if this window does not appear, the problem is your compiler, your linker, your GPU driver or your emsdk. One thing not to do with it: the idle behaviour lives in rcRunApp’s loop, not in the frame call. rcRunFrame is non-blocking by contract, so a hand-rolled while (rcRunFrame(app)) { } sleeps nowhere: it spins a core for as long as the window is open, where rcRunApp blocks in the operating system's event loop and parks. Use rcRunApp unless you genuinely have to own the frame loop.
Step two
Add RayClay to your build.
Three lines. RayClay is vendored inside your project - a git submodule or a copied folder - and one link line pulls in everything it needs.
add_subdirectory(rayclay) # vendored (git submodule or copy)
add_executable(myapp main.c)
target_link_libraries(myapp PRIVATE rayclay) # GLFW, sokol, Clay, stb come with it
FetchContent works today: FetchContent_MakeAvailable() resolves to exactly this add_subdirectory, so you can pull RayClay from a git tag instead of vendoring it.There is deliberately no install() or find_package() path, and that is a decision rather than a gap. Every dependency - Clay, sokol, stb, the patched GLFW, nobar - is vendored, so a correct install(EXPORT) would have to re-export that whole closure. Embedding the source in your build is the supported model.
Two build defines are worth knowing early, and where each one goes is not the same. Read the placement in each item rather than assuming one rule covers both: a define on the wrong target is read by nothing.
target_compile_definitions(rayclay PRIVATE RC_NO_BUNDLED_FONT=1) # reaches the implementation
target_compile_definitions(myapp PRIVATE RC_NO_BUNDLED_FONT=1) # a silent no-op
With add_subdirectory or FetchContent, RayClay compiles its implementation in its own generated translation unit and your main.c gets declarations only. A define on your target is therefore read by nothing, and no warning tells you so.
Which target, and what getting it wrong costs
RC_NO_BUNDLED_FONT=1, onrayclay.- Drops the bundled Latin-1 Roboto subset, and with it the reason your binary can draw text at all - a fontless build renders none, so this define comes with an obligation to supply your own face through
RC_AppOptions.fontPath. On the wrong target it does nothing whatsoever, quietly. RC_PERF_COUNTERS=1, on both targets.- Makes
rcAppPerfFrame()exist - it is the one public function not compiled in by default - so you can read what a frame actually cost. It needs the define onrayclayto be compiled in and on your own target to be visible, because the header hides both the prototype and theRC_PerfFrametype behind it. This is the knob where the mistake is loud rather than silent: on your target alone you get a link error naming the symbol,undefined reference to 'rcAppPerfFrame'; on the library alone, a compile error in your own file,unknown type name 'RC_PerfFrame'. You cannot end up staring at a table of plausible zeroes. - Three knobs invert the rule.
RC_NO_UI_HELPERS,RC_NO_STYLEandRC_NO_COLOR_PALETTEtrim your view of the header, and must not reach the implementation - which needs the declarations in full.
Step three
Build and run it on the desktop.
The configure-and-build pair is identical on Windows, macOS and Linux. Only the path you run afterwards differs.
cmake -B build-desktop
cmake --build build-desktop
./build-desktop/myapp # Windows (multi-config): .\build-desktop\Debug\myapp.exe
-DCMAKE_BUILD_TYPE=Release at configure time for a release build; a multi-config generator such as Visual Studio takes the configuration at build time instead, through --config.On Windows your app opens a console window beside itself. That is the linker’s default rather than anything RayClay does, and it is where the RAYCLAY[…] lines land, which is useful while you are building and is not what you ship. Closing it is a linker setting on your side rather than a RayClay one, and it costs you stdout and stderr both, so decide where the log goes before you silence it. The cheatsheet names the cause; the per-toolchain flags are in the library's own API notes (opens in a new tab).
What should it weigh? An artefact size is a property of the build type and of what you linked, so the honest answer is ls -l build-desktop/myapp on your own build rather than a range copied from somebody else’s. The library’s own notes do publish figures for a minimal consumer app, and they tell you to reproduce the saving rather than the absolute, for that same reason. There is no size flag to pass: section garbage collection is already on by default outside MSVC, so the smaller build costs you nothing to ask for.
A successful first run looks like this: the window appears with the welcome canvas, the one warning above is on stderr, and then nothing happens. Nothing happening is the success signal rather than a hang - on-demand rendering is the default, so an idle window parks. RayClay's own documentation, measuring its reference machine, puts an idle hello-world window at 0.00 CPU-seconds per minute on the default on-demand runner against 1.08 if you force a continuous redraw, and a 60-second idle at about two frames. That zero is one machine's reading, not a portable property: on the three machines behind the comparison page, the same empty window idles at 0.00 CPU-seconds per minute on Linux, about 0.01 on macOS and about 0.05 on Windows. Move the pointer over it and it draws again.
When it cannot get a window at all, it does not pretend to have drawn one. With the window system genuinely unavailable RayClay names each layer on the way down and exits non-zero.
RAYCLAY[ERROR]: rc_window (GLFW): Failed to detect any supported platform (0x1000E)
RAYCLAY[ERROR]: rc_window: glfwInit failed
RAYCLAY[ERROR]: rcInitWindow: window-system init failed
RAYCLAY[ERROR]: rcAppCreate: window creation failed # exit 1
Step four
Build for the web, from the same source.
The web target is the same source file. It needs one extra CMake block and no #ifdef in your own code, and because the runtime is single-threaded it needs no special cross-origin isolation headers from whatever serves it.
The toolchain is Emscripten, and the version is load-bearing: install 6.0.2, not latest. RayClay is tested against the 6.0.x series and its CI pins 6.0.2.
git clone https://github.com/emscripten-core/emsdk.git
cd emsdk && ./emsdk install 6.0.2 && ./emsdk activate 6.0.2
source ./emsdk_env.sh # sets $EMSDK + puts emcc on PATH (re-run per shell)
emcc --version # expect 6.0.2
source per shell.Your main.c is byte-for-byte the same file on both targets; the frame loop is inverted internally for the browser. What changes is the link - a browser target needs emscripten’s flags for the GLFW3 shim over WebGL2, and it has to emit a page rather than an executable. Add this to the myapp target from step two and you have the whole web-specific diff.
if(EMSCRIPTEN)
set_target_properties(myapp PROPERTIES SUFFIX ".html") # emit myapp.html, not a binary
target_link_options(myapp PRIVATE
-sUSE_GLFW=3 # the windowing shim RayClay targets
-sMIN_WEBGL_VERSION=2 -sMAX_WEBGL_VERSION=2 -sFULL_ES3
-sALLOW_MEMORY_GROWTH=1 -sGROWABLE_ARRAYBUFFERS=0
-sSTACK_SIZE=8MB -sENVIRONMENT=web -sMALLOC=emmalloc)
endif()
Two pins that look like tuning
- emsdk 6.0.2, not
latest. - It has bitten in both directions. Too old, and the pointer is only correct because emscripten’s GLFW shim reports canvas-relative CSS pixels - the space the layout engine hit-tests in - so on a shim that reports device pixels every click lands in the wrong place at a device-pixel-ratio other than 1. From 6.0.2 onward the flag below arrives switched on, which is why the build turns it off explicitly. Nothing in the build enforces a version, so a drifting emsdk fails silently: it still compiles.
-sGROWABLE_ARRAYBUFFERS=0.- emcc 6.0.2 turned it on by default alongside
ALLOW_MEMORY_GROWTH, which backs the heap views with a resizableArrayBuffer- and Chrome’s WebGL rejects those:texSubImage2D: … must not be resizable. The glyph-atlas upload then faults and the page renders blank while the build stays green.
Configure with emscripten’s wrapper, build, and serve the directory.
emcmake cmake -B build-web -DCMAKE_BUILD_TYPE=MinSizeRel
cmake --build build-web --parallel 4 # -> build-web/myapp.{html,js,wasm}
python3 -m http.server 8080 --directory build-web # open http://localhost:8080/myapp.html
wasm and fetch on file://. RayClay is single-threaded, so no COOP or COEP headers are needed and any static server will do.The .wasm is not standalone: it needs its .js loader beside it, so serve and ship the whole set. How big it is has the same answer as the desktop binary, and the docs say it outright - wasm size is a property of the build type, so measure yours with ls -l build-web/*.wasm rather than trusting a range.
In DevTools a RayClay diagnostic arrives at its own severity: errors as console.error, warnings as console.warn, anything informational as console.log. So you can filter the console by level and get the answer you expect, and the welcome-canvas message from step one shows up as a warning rather than an error. If you would rather route the lot somewhere of your own, rcSetLogSink takes precedence and hands you the level as a value.
Step five
Turn the window into an app.
Swap the NULL for an options struct. That is the whole difference between the two-line program and a real app - same call, same shape, you only ever add lines. The layout is a flexbox DSL with Tailwind-shaped option names, expanded by the C preprocessor.
#include "rayclay.h"
static void layout(RC_App *app, void *user) {
(void)app; (void)user;
RC_Style s = rcGetStyle();
rcColumn(.w = "grow", .h = "grow", .bg = s.background, .p = 24, .gap = 16) {
rcTextL("My app", .color = s.text);
if (rcButton("go", "Click me", RC_BTN_PRIMARY)) {
/* handle the click */
}
}
}
int main(void) {
RC_AppOptions opts = {
.width = 900, .height = 600, .title = "My app",
.layoutCallback = layout,
};
return rcRunApp(&opts);
}
.layoutCallback is the only field with no default, and leaving it unset is not an error - you get the welcome canvas, exactly as rcRunApp(NULL) does, plus the one warning at startup saying so. Everything else zero-initialises to something sensible, including .clearColor, which falls back to the active style’s background rather than to transparent black - a snapshot of the theme installed at that moment, not a live link, so change it later with rcAppSetClearColor.
Now make it yours. The state is an ordinary struct that you own and that lives wherever you put it; it reaches all three callbacks through .userData. Every widget takes a unique string id and a pointer to your storage - there is no hook slot and no setter, so rcCheckbox writes through your bool *, rcSlider clamps into your float *, rcTextInput edits your buffer in place, and each returns true on the frame its value changed.
#include "rayclay.h"
typedef struct {
char name[64];
float volume; /* 0..1 */
bool muted;
int saves;
} AppState;
static void layout(RC_App *app, void *user) {
AppState *st = (AppState *)user;
RC_Style s = rcGetStyle();
rcColumn(.id = "root", .w = "grow", .h = "grow",
.bg = s.background, .p = 24, .gap = 16) {
rcTextL("Preferences", .color = s.text, .size = 22);
rcTextInput("name", st->name, sizeof st->name);
rcCheckbox("muted", "Mute notifications", &st->muted);
rcSlider("volume", &st->volume, 0.0f, 1.0f);
if (rcButton("save", "Save", RC_BTN_PRIMARY))
st->saves++;
rcText(rcFormat(rcAppArena(app), "Saved %d times", st->saves),
.color = s.text);
}
}
int main(void) {
AppState st = { .volume = 0.6f };
RC_AppOptions opts = {
.width = 900, .height = 600, .title = "Preferences",
.layoutCallback = layout,
.userData = &st,
.scratchArenaBytes = 4096, /* rcFormat needs this; 0 means off */
};
return rcRunApp(&opts);
}
malloc, no destructor, no re-render bookkeeping. st lives in main, every widget reads and writes it directly, and the whole UI is rebuilt from it each frame.scratchArenaBytes is the one field where 0 means off rather than a sensible default. Leave it and rcFormat renders the visible placeholder <set scratchArenaBytes> instead of your text - which is at least a placeholder you can see, rather than an empty label you have to reason about. Set it and rcFormat(rcAppArena(app), "%d fps", n) works. The alternative is to format into your own buffer with snprintf and pass that to rcTextC: RayClay retains the pointer until the frame is drawn, so keep the buffer alive that long.
One rule will catch you the moment you have a list, and no compiler warns about it at any level.
Never jump out of an element body
- A container’s braces are a macro-generated loop, not a plain block.
- So C’s jump keywords do not mean what they look like inside one.
continueis safe;breakis safe but almost never what you mean.continueends the element body cleanly.breakends the element body too - not yourforloop, which keeps going. Measured: abreakati == 2of 5 still rani = 3andi = 4.gotoorreturnout of the body is unsupported.- The close is skipped and the element stack is left unbalanced. The frame is still completed - but it is the wrong frame: every element you would have declared after the escape is missing, every frame, and a sibling declared after that point can vanish entirely. The diagnostic is latched once per app rather than repeated per frame, so it scrolls away while the broken UI stays on screen. That is what makes this harder to notice than a crash: a crash gets investigated, half a missing UI gets shipped.
int stop = 0;
for (int i = 0; i < n && !stop; i++) {
rcBox(.id = ids[i]) { if (rows[i].last) stop = 1; } /* leaves cleanly */
}
When it goes wrong
The first run, and what it usually catches on.
Four failures are worth knowing before you meet them. Three of them announce themselves in an exact form of words, so match your terminal to the message rather than to the description. The remaining one announces nothing, which is why it is here.
Your C++ project stops at the #include. RayClay itself is pure C99 and needs no C++ toolchain, and the header is wrapped in extern "C" - but the vendored layout engine sets a floor and refuses everything below it, for a declarations-only include.
#error "Clay requires C99, C++20, or MSVC"
-std=c++17, clean at -std=c++20. Build your app at C++20 or later. MSVC is exempt by the same condition.The compile succeeds and the link does not. RayClay is not header-only. The header gives you declarations; the implementation lives in the library, together with all of its bundled dependencies, so gcc main.c or clang main.c compiles your file and then has nothing to resolve the symbols against.
undefined reference to 'rcRunApp'
rcInitWindow, or any other entry point. Build through CMake: target_link_libraries(myapp PRIVATE rayclay) pulls in the library, the vendored windowing and render dependencies and the system OpenGL libraries in one line. There is no supported raw-compiler build - hand-linking would mean compiling all of GLFW for your platform yourself.A build define did nothing, and nothing said so. This is the failure with no message, which is exactly why it is worth knowing before you meet it: a knob on your own target is read by nobody, because the implementation is a different translation unit. The measurable symptom is that your binary is byte-for-byte identical to the one you built before you set it. Put it on the rayclay target, as in step two - except for RC_NO_UI_HELPERS, RC_NO_STYLE and RC_NO_COLOR_PALETTE, which belong on yours.
The link fails on Win32 symbols, in a build that is not using RayClay’s CMake. If you are hand-rolling a compile line or vendoring the sources into another build system, the custom titlebar calls into five Win32 libraries: comctl32, dwmapi, shell32, user32 and gdi32. MSVC auto-links them, since the headers request them with #pragma comment(lib, ...); every GCC-family toolchain - MinGW-w64, MSYS2, clang in GNU mode - silently ignores that directive, so the compile succeeds and the link fails on SetWindowSubclass and DefSubclassProc. gdi32 is the one people forget, and it turns up as CreateRectRgnIndirect and SetWindowRgn. Name all five: comctl32, dwmapi and gdi32 are the true minimum, and the other two merely happen to sit in MinGW’s default link set today. Through the CMake path in step two, none of this is your problem - RayClay names the OS libraries it needs.
If you know React, read this next Every function, one line each