Building Data-Driven UI
Combine data bindings and CSS to build common user interface patterns.
On this page
You can combine data bindings with CSS and native code to build common game UI patterns, from animated toasts to windowed lists.
Each pattern separates raw data in native code from visual presentation and animation in the page.
Example Data
The first examples use a trimmed version of the Player type from Showing Data in Markup. It keeps name and stats (health and armor), leaves out unused fields like level and gold, and adds charge and a stance enum— plain types like this need no schema:
enum class Stance { Idle, Blocking, Stunned };
struct Player {
std::string name;
double charge = 0; // 0 to 1
Stance stance = Stance::Idle;
Stats stats;
};
Native code sets initial values and binds the player as player:
Player player { .name = "Ava", .charge = 0.25, .stats = { .health = 80 } };
dd::Context ctx = dd::Context::Create();
dd::Binding binding = ctx.Bind("player", player);
Later sections define their own types for toasts, a server list, and a leaderboard.
Presenting Numbers with CSS
Native code publishes raw numbers like health and charge, leaving visual presentation to CSS. A designer can restyle the UI without changes to native code.
While ul-style-width sets a single style property, binding to a CSS variable lets one number control several properties at once.
Set ul-var-hp on an element to expose player.stats.health as the --hp variable:
<div class="health-track">
<div class="health-bar" ul-var-hp="player.stats.health"></div>
</div>
In CSS, use --hp to set both the bar's width and its background color:
.health-bar {
width: calc(var(--hp) * 1%);
background: hsl(calc(var(--hp) * 1.2), 70%, 45%); /* green to red */
}
Ring Gauges
A normalized value from 0 to 1 can control a radial cooldown ring using a conic gradient.
Expose the charge value as a CSS variable on a circular element:
<div class="ring" ul-var-charge="player.charge"></div>
In CSS, convert the variable to turns inside conic-gradient():
.ring {
width: 60px;
height: 60px;
border-radius: 50%;
background: conic-gradient(gold calc(var(--charge) * 1turn), #333 0);
}
Smoothing Changes
Add a CSS transition to animate property changes smoothly when native values update.
Adding a transition to the derived width property animates the bar instead of snapping to the new value:
.health-bar { transition: width 200ms linear; }
When health drops from 80 to 20 in native code, the bar drains smoothly over 200 ms without any extra code.
Values That Change Every Frame
Declaring dd::Var in a schema from Describing Types with Schemas places the CSS variable on the page's root element. Each change recomputes every style in the document that references that variable.
For values that change every frame, use ul-var-* on the specific element that consumes the value instead— this avoids document-wide style recalculations.
Styling States with an Enum
Enum fields serialize to their enumerator names as text.
Bind the enum to a data attribute with ul-attr::
<div class="portrait" ul-attr:data-stance="player.stance"></div>
In CSS, target each state with an attribute selector and add transitions as usual:
.portrait { transition: background-color 150ms; }
.portrait[data-stance="Blocking"] { background-color: steelblue; }
.portrait[data-stance="Stunned"] { background-color: goldenrod; }
Animating Rows In and Out
Erasing an item directly from a container causes Sync() to remove the row elements immediately, leaving no time for an exit animation. Because the engine doesn't support CSS @starting-style or transition-behavior, animating row removal requires a two-step pattern.
Native code flags the item as leaving rather than erasing it right away. CSS fades the row out, and the page fires a row action when the animation completes so native code can erase the item.
The toast types below declare a leaving field and a gone action:
struct Toast {
int64_t id = 0;
std::string text;
bool leaving = false;
static constexpr auto schema = dd::Schema(
dd::Field("text", &Toast::text),
dd::Field("leaving", &Toast::leaving),
dd::Action("gone")); // the page fires it when the fade ends
};
struct Hud {
std::vector<Toast> toasts;
static constexpr auto schema = dd::Schema(
dd::List("toasts", &Hud::toasts, &Toast::id));
};
The list must use a key from Binding Lists so each row retains its DOM elements and active transitions across updates.
Entering Rows
In the template, bind the leaving class and listen for transitionend to fire the gone action:
<ul class="toasts" ul-for="hud.toasts">
<template>
<li class="toast" ul-class-leaving="leaving"
ul-on:transitionend="gone">{{text}}</li>
</template>
</ul>
Define an entrance animation on the row class using standard CSS @keyframes:
.toast { animation: slide-in 300ms ease-out; }
@keyframes slide-in { from { transform: translateX(100px); } }
Native code binds the HUD and adds a toast— the next Sync() adds the row and starts the entrance animation:
Hud hud;
dd::Binding hud_binding = ctx.Bind("hud", hud);
hud.toasts.push_back({ .id = next_id++, .text = "Level up!" });
ctx.Sync();
Leaving Rows
In CSS, set up a transition that lowers opacity when the leaving class is present:
.toast { transition: opacity 300ms; }
.toast.leaving { opacity: 0; }
To dismiss a toast, set leaving to true while keeping the item in the list:
hud.toasts.front().leaving = true; // fades out, then the page fires "gone"
ctx.Sync();
Handle the toasts.gone action by erasing the item by its key:
hud_binding.OnAction<"toasts.gone">([&](const Toast& gone) {
std::erase_if(hud.toasts, [&](const Toast& t) { return t.id == gone.id; });
});
If a row has no active transition (such as when its container is hidden with ul-show), the transitionend event never fires and the row remains in the list.
Showing Long Lists
Rendering thousands of DOM elements slows down layout and style calculations. Because Ultralight doesn't support CSS content-visibility, use windowing to render only the visible rows.
Native code exposes a slice of visible rows and spacer heights for the unrendered items above and below. This keeps the scrollbar sized accurately to the full list. Every row must have the same fixed height for the spacer math to work.
The server browser below stores 10,000 servers but exposes only 30 rows at a time:
struct Server {
int64_t id = 0;
std::string name;
int players = 0;
};
struct ServerList {
static constexpr int kRowHeight = 20; // matches .server { height: 20px }
static constexpr int kRows = 30; // a screenful plus a margin
std::vector<Server> all; // every server (eg, 10,000)
int first = 0; // the first row in the page
int last() const { return std::min(first + kRows, int(all.size())); }
};
A dd::TypeTraits specialization provides the schema, using std::span to expose the window without copying, calculating spacer heights, and tagging scroll with dd::OnlyLatest from Handling Actions:
template <> struct dd::TypeTraits<ServerList> {
static constexpr auto schema = dd::Schema(
dd::List("shown", [](const ServerList& s) {
return std::span(s.all).subspan(s.first, s.last() - s.first);
}, &Server::id),
dd::Field("above", [](const ServerList& s) {
return s.first * ServerList::kRowHeight;
}),
dd::Field("below", [](const ServerList& s) {
return (int(s.all.size()) - s.last()) * ServerList::kRowHeight;
}),
dd::Action<double>("scroll", dd::OnlyLatest));
};
In markup, place spacer elements above and below the ul-for container, formatting their heights with |px:
<div id="servers" class="scroller">
<div ul-style-height="servers.above|px"></div>
<div ul-for="servers.shown">
<template>
<div class="server">{{name}} ({{players}})</div>
</template>
</div>
<div ul-style-height="servers.below|px"></div>
</div>
In CSS, set the scroll container height and ensure .server matches kRowHeight:
.scroller { height: 400px; overflow-y: auto; }
.server { height: 20px; }
Reporting the Scroll Position
Actions fired from markup cannot pass a payload, so use a native DOM listener from DOM Triggers and Navigation to read scrollTop and post the action:
dom::Triggers ui;
ui.On("#servers", "scroll", [&ctx](dom::Element list) {
double y = list.scrollTop;
ctx.PostAction("servers.scroll", y);
}, dom::Capture); // scroll events don't bubble
if (!ui.AttachTo(view.get()))
Log("couldn't attach the listeners");
🚧 Capturing Scroll Events
A scroll event on an element doesn't bubble. A listener registered with
Triggers::On()withoutdom::Capturenever fires, leaving the list stuck on its initial rows.
Moving the Window
Native code binds the list and handles the scroll action, keeping five rows of margin above the viewport:
ServerList servers; // filled with 10,000 servers
dd::Binding list_binding = ctx.Bind("servers", servers);
list_binding.OnAction<"scroll">([&](double y) {
int row = int(y) / ServerList::kRowHeight;
int max_first = std::max(0, int(servers.all.size()) - ServerList::kRows);
servers.first = std::clamp(row - 5, 0, max_first); // 5 rows of margin above
});
At the next Sync(), native code sends the updated slice and spacer heights. Because the combined height of the spacers and visible rows never changes, the scroll position stays in place.
Skipping Unchanged Data
By default, Sync() reads every field and row of each bound instance. While it only transmits values that changed, reading a large collection each frame adds unnecessary overhead.
You can declare a static Sync() function on the type to control what gets read. Gating the list on an internal revision counter marked dd::Internal skips reading the collection until the data changes.
In the leaderboard below, Sync() checks rev against the last synced value before reading the rows:
struct Score {
int64_t id = 0;
std::string name;
int points = 0;
};
struct Leaderboard {
int rev = 1; // bump it whenever rows change
std::string title;
std::vector<Score> rows;
static constexpr auto schema = dd::Schema(
dd::Field("rev", &Leaderboard::rev, dd::Internal),
dd::Field("title", &Leaderboard::title),
dd::List("rows", &Leaderboard::rows, &Score::id));
static void Sync(const Leaderboard& b, dd::Model<Leaderboard> m) {
if (b.rev != m.Get<"rev">()) // compare before syncing rev
m.Sync<"rows">();
m.SyncAllExcept<"rows">(); // syncs rev and title
}
};
When modifying the row collection, increment rev to trigger a re-read on the next Sync():
board.rows.push_back({ .id = 42, .name = "Ava", .points = 900 });
board.rev++; // the next Sync reads the rows again
Fields like title continue to sync normally via SyncAllExcept<"rows">() without needing a revision bump.
🚧 Comparing Revision Counters
Compare the revision counter before syncing it. Calling
Sync()on the counter updates the snapshot, so subsequent checks will always find them equal. Start the counter at 1 rather than 0— before the first sync,Get()returns 0, so starting at 0 skips the initial sync and leaves the list blank.
Working with Page Script
Data bindings work without page script, and JavaScript running on the page has no API to read or modify bound data directly.
Firing Actions
To fire an action without a payload from script, dispatch the event that the ul-on element listens for. This example uses the Player type from Handling Actions, which declares the usePotion action:
<button id="potion" ul-on:click="player.usePotion">Drink</button>
<script>
addEventListener("keydown", (e) => {
if (e.key === "q")
document.getElementById("potion").click(); // fires player.usePotion
});
</script>
An action fired from markup never carries a payload. To send an action with a payload from script, expose a native function that calls PostAction()— see Extending JavaScript with Native API.
Modifying Bound Elements
When page script changes an element managed by a binding (such as text set with ul-text), the change lasts until native code updates that value. Calling Sync() when the native value hasn't changed leaves the script's change in place— the renderer only rewrites values that changed.
Waiting for Initial Data
To run script after the first update arrives from native code, check for the ul-ready class on the root element— see Showing Data in Markup.
Pages using mock data tools can await the ul.ready promise from ul-mock.js— see Mocking Bound Pages. A page without that script has no ul.ready.
🚧 The ul Global
Never define a global variable named
ulon the page. The mock data tools use it, and the renderer createsul.diagnosticswhen developer mode is on and a native JavaScript API is attached to the View— see Extending JavaScript with Native API. On that View,var ulandwindow.ulfail silently, andlet ulthrows an error.