DEV Community

Cover image for Building Vel: A Token-Efficient, Compile-to-Native UI Language
Sai Chandan Kadarla
Sai Chandan Kadarla

Posted on • Originally published at saichandankadarla.com

Building Vel: A Token-Efficient, Compile-to-Native UI Language

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)
Enter fullscreen mode Exit fullscreen mode
  • 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 .vel files; the language has cross-file imports as a first-class feature.
  • Application: your .vel files and a thin main.cpp shell. 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>
Enter fullscreen mode Exit fullscreen mode
V p=lg g=md bg=bg0 r=md
  T "Hello" font=bold
Enter fullscreen mode Exit fullscreen mode

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}"
Enter fullscreen mode Exit fullscreen mode

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 $count as a dependency, and binds the computed to it. Any count_.set(...) propagates: count → computed → component → needsRebuild_ = true.
  • AsyncSignal<std::vector<User>> users_ — kicked off with std::async(std::launch::async, ...), polled each tick. Exposes .loading / .ready / .value / .error as method calls in the DSL.
  • The component constructor .listen()s every signal it owns. The next tick() calls rebuild(), re-measures, and re-places before the frame paints. Tree rebuilds are coarse-grained — like React's setState, 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
Enter fullscreen mode Exit fullscreen mode

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=Stretch degrades to Start so children don't get infinite size cascades.
  • Frame-level damage tracking: an atomic frameDirty flag, raised by any Widget::markDirty() call, controls whether the next frame runs at all. Animating widgets re-arm the flag from their tick(); static pages don't.
  • Portal layer for overlays: tooltips, popovers, dialogs, sheets, menus, and toasts go through an OverlayHost that 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 with clipContent" 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
Enter fullscreen mode Exit fullscreen mode
// 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%"
Enter fullscreen mode Exit fullscreen mode

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 --watch mode.
  • Reactive substrate: Signal<T>, Computed<T>, AsyncSignal<T>, PersistentSignal<T> (auto-syncs to disk), effects, typed ctx<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.json regenerated on every build; cross-file imports; --watch for 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)