docs

About Data Bindings

Bind native data to HTML markup and respond to page events in application code.

On this page

Data bindings connect native data to page HTML and CSS.

Your markup declares where each value appears and which buttons and input trigger which actions, and the library keeps the page in sync automatically.

This offers a number of benefits over syncing the page with your application via JavaScript:

Benefit Details
Faster performance All logic is performed in native code, and layout / style re-calc is batched with data modifications.
Lower memory Native data is bound directly to the DOM without any intermediate JavaScript values.
Easier to write Keep styles and layout in HTML and CSS and keep the data model and logic in native code (hello MVC!). Use mock data to easily design pages in any browser.
Safer to use Validate data bindings in HTML using automatic schema export from native code.

Going From DOM API to Data Bindings

Ultralight already offers API to manipulate JS and DOM directly from native code so why would you use data bindings?

The short answer is that the former tightly couples application logic to page presentation, which is bad for maintainability (what if you decide to change your page's layout or element ID names?).

Let's compare the two:

With the DOM API

With the DOM API, native code updates each element itself every time data changes:

C++
document.getElementById("name").textContent = player.name();
document.getElementById("armor").textContent =
    std::to_string(player.stats().armor);

With Data Bindings

With data bindings, your HTML and CSS markup declare where each value goes:

HTML
<h1 ul-text="player.name"></h1>
<p>Armor: {{player.stats.armor}}</p>

Then in native code, you notify the engine whenever you want to broadcast data changes to the page (usually once per frame).

C++
ctx.Sync();

The markup looks familiar if you use Vue or Alpine.js in JavaScript, but the data lives in native code rather than in page scripts.

You can still use the DOM API for one-off work like focusing a field or measuring an element— both approaches mix on one page.

Getting Started

1. Describe Your Types

A schema lists the fields that native code exposes to the page and the actions the page can fire in native code.

Include <Ultralight/dom/data/Context.h> to access the data binding types (they aren't included by <Ultralight/DOM.h>).

Plain structs bind automatically by member name without a schema, while existing classes declare a dd::TypeTraits specialization:

C++
#include <Ultralight/dom/data/Context.h>

namespace dd = ultralight::dom::data;

// A plain struct binds as-is: stats.health, stats.armor.
struct Stats {
  int health = 100;
  int armor = 0;
};

// An existing class from your game.
class Player {
 public:
  const std::string& name() const;
  const Stats& stats() const;
  void TakeDamage(int amount);
  void UsePotion();
};

template <> struct dd::TypeTraits<Player> {
  static constexpr auto schema = dd::Schema(
      dd::Field("name", &Player::name),     // reads player.name()
      dd::Field("stats", &Player::stats),   // a nested object
      dd::Action("usePotion"));             // an action the page can fire
};

2. Bind and Attach

A dd::Context connects native objects to the pages loaded by one or more View instances.

Call Bind() to register an instance under a name the page can use, and chain handlers to receive actions from the page into native code:

C++
dd::Context ctx = dd::Context::Create();
if (!ctx.AttachTo(view.get()))
  Log("couldn't attach the data context");

Player& player = game.LocalPlayer();              // your existing object
dd::Binding binding = ctx.Bind("player", player)  // the page calls it "player"
    .OnAction<"usePotion">(&Player::UsePotion);   // the button below calls this

view->LoadURL("file:///character.html");

3. Use It in the Page

In HTML, you reference data with ul-* attributes and {{path}} text:

HTML
<div class="character-panel">
  <h1 ul-text="player.name"></h1>
  <p>Armor: {{player.stats.armor}}</p>
  <progress max="100" ul-attr:value="player.stats.health"></progress>
  <button ul-on:click="player.usePotion">Drink Potion</button>
</div>

4. Change Your Data and Sync

Call Sync() once per frame to deliver page events to native handlers and push data to the page:

C++
player.TakeDamage(10);
ctx.Sync();  // runs UsePotion() if clicked, then updates the page

Each call to Sync() performs three operations in order:

  1. Delivers events from the page to native handlers (changes first, then actions).
  2. Reads every bound object through its schema.
  3. Broadcasts changed values from native code to the page in a single batch.

How Data and Input Flow

Data flows from native code to the page, while requests such as actions and change requests flow from the page to native code.

Direction What Travels Markup Native Code
Native code to the page Data (fields, lists, and CSS variables) {{path}}, ul-text, ul-attr, ul-class, ul-style, ul-var, ul-show, ul-if, ul-for Modify native objects, call Sync()
Page to native code Actions (clicks or other events) ul-on:click OnAction()
Page to native code Value changes in form fields ul-value OnChange()

Two-Way Binding with ul-value

The only two-way binding is ul-value (eg, a slider bound to settings.volume). It works as a round trip rather than a shared variable:

  1. The user moves the slider on the page. The page reflects the new position immediately and sends a change request from the page to native code.
  2. The OnChange() handler validates the data (potentially modifying / blocking it) and updates the corresponding value in native data. The next Sync() sends the confirmed value back to the page.

Designing Pages with Mock Data

You can build and style data-bound pages in any browser without running the application.

The generator creates mockup files from the application's schema so the page displays real model data. It can also check markup against the schema in a build step— typos in binding paths get caught before the page runs.

  1. Save the schema and current values of every bound model from native code (export this in development builds only):
C++
ctx.DumpSchema("ui/schema.json");  // written at the end of the next Sync()
  1. Run gen-mock-data.py from the SDK root folder (the directory holding tools/, not from inside tools/scripts):
Shell
python tools/scripts/gen-mock-data.py ui/schema.json -o ui/

This creates the mockup scripts beside the pages. The mock-data.js file is written only once, so future runs preserve any custom mock data added to it.

  1. Add the mock script tag to the page:
HTML
<script src="ul-mock.js"></script>

Open the HTML file in any browser to inspect the page with the saved values. The script loads nothing inside Ultralight, so the tag can stay in the shipped page.

To define custom mock data, handle actions, test scenarios, or run the check build step, see Mocking Bound Pages.

For C projects, see Data Bindings in C.