I've been building Vel — a new declarative UI language plus the runtime and component registry to back it. The goal is unapologetically ambitious: write production desktop apps with the expressive density of Tailwind classes, the reactivity model of SolidJS, and the rendering quality of Flutter, while letting you call into any existing C/C++ backend without a binding layer.
This post walks through the architecture I landed on, why each tier exists, and what the language actually buys you in practice.
The three-tier rule
Most UI frameworks blur three concerns: the language, the runtime substrate, and the opinionated widget set. Vel splits them on purpose, with a strict one-way dependency:
language → framework → registry → application
(velc) (libvel) (vel::ui::*) (.vel + main.cpp)
-
Language: a small, indentation-sensitive DSL (
.vel) and a single-pass compiler (velc). It parses to an AST, type-checks against a primitive registry, and emits idiomatic modern C++ that consumes the framework. -
Framework (
libvel): the substrate. Widget pipeline (measure / place / paint / tick), Skia-backed Painter, an event dispatcher, the reactive primitives (Signal<T>,Computed<T>,AsyncSignal<T>), and infra (router, storage, net shim, IO, time, validate, notify, JSON, typed context). No application policy — no themes, no shortcuts, no business rules. -
Registry (
vel::ui::*): 43 baseline widgets in the shadcn idiom — Btn, Card, Tabs, Accordion, Dialog, Sheet, Table, TreeView, Stepper, Image, and so on. Built by composing framework primitives. Third parties can ship their own registries as plain.velfiles; the language has cross-file imports as a first-class feature. -
Application: your
.velfiles and a thinmain.cppshell. Themes, keyboard shortcuts, and routing decisions live here.
Code in a higher tier never reaches into a lower one. The framework never imports an icon set. The runtime never hardcodes a key combo. This isn't religion; it's the only way the language stays composable as third-party registries appear.
Why a DSL — and why compile to C++?
Two reasons.
First, source-token density. The same UI in JSX vs. Vel:
<View style={{padding: 24, gap: 12, backgroundColor: theme.bg0, borderRadius: 12}}>
<Text style={{color: theme.fg, fontWeight: '700'}}>Hello</Text>
</View>
V p=lg g=md bg=bg0 r=md
T "Hello" font=bold
That's roughly a 4–6× compression on real components without losing the declarative shape. For LLMs writing UI, that's the difference between fitting a feature in context and not. Vel was designed from day one with agent-authored code as a primary user.
Second, no VM at runtime. Vel compiles directly to typed C++ that calls into a static libvel. There's no interpreter loop, no JIT, no garbage collector. Layout, paint, and event dispatch are all monomorphic virtual calls on stable widget instances. On idle, the app sits in glfwWaitEventsTimeout and uses ~0 CPU — a global frame-dirty flag short-circuits the entire pipeline until something changes.
The reactive substrate
The keystone primitive is Signal<T> — a value plus a listener list. Everything reactive flows from it.
In the DSL, @state declarations become typed Signal<T> members on the generated component:
#Counter
@count = 0
@doubled = $count * 2 // detected as derived → Computed<int>
@users: std::vector<User> = await fetchUsers() // → AsyncSignal<...>
V g=md
T "Count: {$count}, doubled: {$doubled}" font=bold
Btn "++" -> click => $count = $count + 1
if $users.loading
Spinner
else
for u in $users.value
T "{u.name}"
What velc emits, semantically:
-
Signal<int> count_{0}— plain reactive cell. -
Computed<int> doubled_{ [this]{ return count_.get() * 2; } }— the codegen walks the init expression's AST, finds$countas a dependency, and binds the computed to it. Anycount_.set(...)propagates: count → computed → component →needsRebuild_ = true. -
AsyncSignal<std::vector<User>> users_— kicked off withstd::async(std::launch::async, ...), polled each tick. Exposes.loading/.ready/.value/.erroras method calls in the DSL. - The component constructor
.listen()s every signal it owns. The nexttick()callsrebuild(), re-measures, and re-places before the frame paints. Tree rebuilds are coarse-grained — like React'ssetState, not Solid's fine-grained reactivity — but the granularity is set per#Component, so it composes cleanly.
Effects (~ $a $b => body) compile to per-dep listeners — declarative side effects without polluting event handlers.
First-class C/C++ interop
The single most important constraint I set: you must be able to drop any existing C++ codebase into Vel with one line. No bindings, no schemas, no IDL. The language gives you two primitives:
use "myorg/db.h" // raw C++ include — verbatim
use "ui/card.vel" // cross-file vel import — pulls in components
Once a header is in scope, qualified names work in every expression. The lexer learned :: so db::fetchUsers(), std::vector<myorg::User>, and await myapp::loadAsync() all parse and codegen straight through to the underlying calls. The framework knows nothing about your backend — velc just emits the include and the call site. Your existing C++ links into the binary like any other translation unit.
This is the reason Vel exists at all. Every other "new UI language" I've tried makes interop a second-class citizen. Here, the interop is part of the type system from day one — state can be of any C++ type, expressions can call any user namespace, and the DSL's type annotations support templates.
Architecture of the rendering pipeline
The render path is Skia all the way down, but Vel adds a few layout patterns worth noting:
-
Constraints model: identical to Flutter (min/max W/H), one measure pass returns intrinsic size, one place pass writes final rects. A small but important fix in Vel — when the cross axis is unbounded (e.g. inside a scrollable),
align=Stretchdegrades toStartso children don't get infinite size cascades. -
Frame-level damage tracking: an atomic
frameDirtyflag, raised by anyWidget::markDirty()call, controls whether the next frame runs at all. Animating widgets re-arm the flag from theirtick(); static pages don't. -
Portal layer for overlays: tooltips, popovers, dialogs, sheets, menus, and toasts go through an
OverlayHostthat owns a per-frame portal queue. Widgets nested inside scrolled containers register their panel rect plus the current scroll offset, so the panel paints in screen space regardless of where in the tree it was declared. This solved the classic "popover gets clipped by a Card withclipContent" problem cleanly. - DPR awareness: the GL context is created at framebuffer resolution, the widget tree lays out in logical points, and the canvas matrix is scaled on each paint. Resize is handled by re-creating the Skia surface against the new framebuffer.
The registry as a primitive, not a library
The 43-widget baseline ships in vel/ui/, but the registry mechanic isn't reserved for the framework. Any .vel file is a registry artifact. Example:
// widgets/stat.vel
#Stat label:str value:str hint:str=""
Card elev=1
V g=xs
T $label fg=fgMuted font=uiSm
T $value font=bold/display
if $hint != ""
T $hint fg=fgFaint font=uiSm
// showcase.vel
use "widgets/stat.vel"
#Showcase
H g=md
Stat label="MAU" value="42,103" hint="last 30 days"
Stat label="Latency" value="84ms" hint="p99"
Stat label="Errors" value="0.02%" hint="↓ 18%"
velc resolves the import, parses the imported file to extract the component's param signature, and at the call site emits std::make_unique<vel::gen::Stat>("MAU", "42,103", "last 30 days") — positional constructor args, type-checked statically by the C++ compiler downstream. Third parties can ship registries as bare .vel files via git, vendoring, or eventually a package manifest.
What's working today
- Compiler with lexer, parser (forward-progress-guarded), type checker, codegen, and
--watchmode. - Reactive substrate:
Signal<T>,Computed<T>,AsyncSignal<T>,PersistentSignal<T>(auto-syncs to disk), effects, typedctx<T>(key)Provider substrate. - Framework primitives:
Router(path matching, history),storage(JSON-backed KV),net(pluggable HTTP shim),io(file ops + watcher),time(debounce / throttle / interval / timeout),validate(form rules),notify(toast bus),json(wraps nlohmann), per-widget cursor states, theme switching with full tree rebuild. - Registry: 43 widgets including Table (virtualized, sortable, multi-select), TreeView, Stepper, Image (Skia-decoded with LRU cache), and the full shadcn-style overlay set.
- DX: agent-readable
widgets.jsonregenerated on every build; cross-file imports;--watchfor hot iteration.
What's next
The big open items are hot reload (file watch is in; dlopen swap of the generated .so is not), a real vel.json package manifest, per-widget damage rectangles (right now damage is frame-level), and a few more registry primitives — Video, SVG, devtools overlay, a syntax-highlighted code editor.
If the three-tier story holds — and so far it does — Vel ends up being the smallest source surface area you can use to ship a production native desktop app, with the deepest backend interop story of any framework I'm aware of.
I'll keep posting as the registry grows.
Top comments (0)