Structural Binding & UI Events
This article covers structural data-binding , where instead of changing styles or text, the binding system adds and removes actual DOM nodes. You will learn how data-bind-if conditionally mounts elements, how data-bind-for generates lists from arrays, and how data-bind-[eventName] attaches event handlers to data-bound elements.
In the previous article, data-binding was used to change what existing elements look like: their text, their inline styles, and their CSS classes. But the set of elements in the DOM stayed the same. Many game UI screens require more than that. An inventory needs to render a variable number of item slots. A buff bar should only appear when the player actually has active buffs. A party roster grows and shrinks as squad members join or leave.
These scenarios require the binding system to modify the structure of the DOM tree itself, not just its visual properties. That
is what data-bind-if and data-bind-for are for. This article also covers data-binding events, which let you wire up click handlers, hover effects,
and other interactions directly from your HTML without writing addEventListener boilerplate.
Conditional Rendering with data-bind-if
Section titled “Conditional Rendering with data-bind-if”The data-bind-if attribute controls whether an element exists in the rendered DOM. The expression must evaluate to a boolean
(or a value that is truthy/falsy). When the condition is true, the element and all of its children are present in the DOM. When it is false, the
engine removes them entirely.
This is fundamentally different from hiding an element with display: none or opacity: 0. Those CSS approaches keep the element in the DOM and the
engine still has to process it during layout. data-bind-if removes the element from the tree altogether.
Basic Usage
Section titled “Basic Usage”The simplest form uses a boolean property from the model:
import Block from '@components/Layout/Block/Block';
const StatusBadge = () => ( <Block class="roster__status-badge" attr:data-bind-if="{{PartyModel.isFull}}"> Party Full </Block>);<!-- Only shows the "Party Full" badge when the flag is true --><div class="roster__status-badge" data-bind-if="{{PartyModel.isFull}}">Party Full</div>You can also use comparison expressions directly in the attribute:
import Block from '@components/Layout/Block/Block';
const RosterWarning = () => ( <Block class="roster__warning" attr:data-bind-if="{{PartyModel.members}}.length < {{PartyModel.requiredSize}}" > Party incomplete! You need more members to start the raid. </Block>);<!-- Show a warning when the party has fewer than the required members --><div class="roster__warning" data-bind-if="{{PartyModel.members}}.length < {{PartyModel.requiredSize}}"> Party incomplete! You need more members to start the raid.</div>When PartyModel.members contains fewer entries than PartyModel.requiredSize, the warning element is mounted into the DOM. The moment the condition
becomes false (enough members have joined), the engine removes the element entirely.
Negate the expression with !:
<!-- Show a "Ready" indicator only when NOT in combat --><div class="roster__ready-indicator" data-bind-if="!{{PartyModel.isInCombat}}">Ready to Deploy</div>Generating Lists with data-bind-for
Section titled “Generating Lists with data-bind-for”While data-bind-if handles the “show or hide” case, data-bind-for handles the “repeat N times” case. It takes an array
property from a model and stamps out a copy of the element (and its children) for each item in that array. This is similar in concept to frameworks
like React’s map, SolidJS’s <For> component, or Vue’s v-for directive, but it runs natively inside the Gameface engine.
Basic Syntax
Section titled “Basic Syntax”The attribute value follows the pattern iteratorName:{{model.arrayProperty}} where iteratorName is the name of the variable that will be used to
iterate over the array and {{model.arrayProperty}} is an array property from the model.
Consider the following model and it’s members array property:
// Each list item must be an objectPartyModel.members = [{ name: "Aria" }, { name: "Bronn" }, { name: "Chen" }];To display the names of the members, keep a single static list container and put data-bind-for on a child wrapper. Bind the iterator only on
elements inside that wrapper:
import Block from '@components/Layout/Block/Block';import TextBlock from '@components/Basic/TextBlock/TextBlock';
const RosterList = () => ( <Block class="roster__list"> <Block class="roster__member-entry" attr:data-bind-for="member:{{PartyModel.members}}"> <TextBlock attr:data-bind-value="{{member.name}}" /> </Block> </Block>);<div class="roster__list"> <div class="roster__member-entry" data-bind-for="member:{{PartyModel.members}}"> <span data-bind-value="{{member.name}}"></span> </div></div>The engine clones the data-bind-for element and its children once per array item. The iterator (member here) is in scope only on descendants, using
{{member.name}}, {{member.level}}, and so on. Three objects in PartyModel.members produce three .roster__member-entry wrappers inside
.roster__list.
The iterator name in iteratorName:{{model.array}} is arbitrary; use a label that matches the data (for example member, item).
Accessing the Loop Index
Section titled “Accessing the Loop Index”You can also capture the loop counter alongside the iterator by using a comma-separated syntax:
<div class="roster__list"> <div class="roster__member-entry" data-bind-for="index, member:{{PartyModel.members}}"> <span data-bind-value="{{index}}"></span>. <span data-bind-value="{{member.name}}"></span> </div></div>The index variable is zero-based and increments for each element. If you only need the index and not the iterator (for example, to generate numbered
placeholders), you can use _ as a throwaway for the iterator:
<!-- Generate 5 empty slots based on array length, only using the index --><div class="roster__list"> <div class="roster__member-entry" data-bind-for="index, _:{{PartyModel.slots}}"> <div class="roster__slot" data-bind-value="{{index}} + 1"></div> </div></div>Iterator-Scoped Bindings
Section titled “Iterator-Scoped Bindings”Let us apply data-bind-for to our party roster. Each member in the array has a name, a class (warrior, mage, etc.), a level, and an HP percentage.
The template stamps out a card for each member:
import Block from '@components/Layout/Block/Block';import InlineTextBlock from '@components/Basic/InlineTextBlock/InlineTextBlock';import TextBlock from '@components/Basic/TextBlock/TextBlock';
const RosterList = () => ( <Block class="roster__list"> <Block class="roster__member-entry" attr:data-bind-for="member:{{PartyModel.members}}"> <Block class="roster__member"> <Block class="roster__member-header"> <TextBlock class="roster__member-name" attr:data-bind-value="{{member.name}}" /> <InlineTextBlock class="roster__member-level"> Lv. <span data-bind-value="{{member.level}}" /> </InlineTextBlock> </Block> <TextBlock class="roster__member-class" attr:data-bind-value="{{member.role}}" /> <Block class="roster__hp-track"> <Block class="roster__hp-fill" attr:data-bind-style-width="{{member.hpPercent}} + '%'" attr:data-bind-class-toggle="roster__hp-fill--low: {{member.hpPercent}} < 30" /> </Block> </Block> </Block> </Block>);<div class="roster__list"> <div class="roster__member-entry" data-bind-for="member:{{PartyModel.members}}"> <div class="roster__member"> <!-- Row 1: Name and Level --> <div class="roster__member-header"> <span class="roster__member-name" data-bind-value="{{member.name}}"></span> <span class="roster__member-level"> Lv. <span data-bind-value="{{member.level}}"></span> </span> </div>
<!-- Row 2: Class label --> <div class="roster__member-class" data-bind-value="{{member.role}}"></div>
<!-- Row 3: HP bar --> <div class="roster__hp-track"> <div class="roster__hp-fill" data-bind-style-width="{{member.hpPercent}} + '%'" data-bind-class-toggle="roster__hp-fill--low: {{member.hpPercent}} < 30"></div> </div> </div> </div></div>Inside a data-bind-for template, the binding types from the previous article
still apply: data-bind-value, data-bind-style-*, data-bind-class-toggle, and data-bind-if on child elements. Each instance references properties
through the iterator (member) rather than a global model name.
Combining data-bind-for with data-bind-if
Section titled “Combining data-bind-for with data-bind-if”You can nest data-bind-if inside a data-bind-for template to conditionally show parts of each repeated element. For example, to display a “Leader”
badge only for the party leader:
import Block from '@components/Layout/Block/Block';import TextBlock from '@components/Basic/TextBlock/TextBlock';
const RosterWithLeader = () => ( <Block class="roster__list"> <Block class="roster__member-entry" attr:data-bind-for="member:{{PartyModel.members}}"> <Block class="roster__member"> <TextBlock class="roster__member-name" attr:data-bind-value="{{member.name}}" /> <TextBlock class="roster__leader-badge" attr:data-bind-if="{{member.isLeader}}"> Leader </TextBlock> </Block> </Block> </Block>);<div class="roster__list"> <div class="roster__member-entry" data-bind-for="member:{{PartyModel.members}}"> <div class="roster__member"> <span class="roster__member-name" data-bind-value="{{member.name}}"></span>
<!-- Only shown for the member flagged as leader --> <span class="roster__leader-badge" data-bind-if="{{member.isLeader}}">Leader</span> </div> </div></div>The data-bind-if inside the loop evaluates per iteration using the member iterator, so each member card independently decides whether to show the
badge.
Data-Binding Events
Section titled “Data-Binding Events”Bindings covered so far push model state to the DOM. Player input flows the other way: clicks, hovers, and focus changes call handlers you declare in
HTML. A common approach is addEventListener on each node:
// In a SolidJS component, event handlers go directly on JSX elements:const RosterMember = (props: { member: any; index: number }) => ( <Block class="roster__member" onClick={(event) => selectMember(event, props.index)}> {/* member content */} </Block>);document.querySelectorAll(".roster__member").forEach((el, i) => { el.addEventListener("click", (event) => { selectMember(event, i); });});This works, but it has two problems in the context of data-bound UIs. First, if data-bind-for regenerates the list (because the array changed), your
manually attached listeners are gone, and you need to re-attach them. Second, you lose the direct connection between the event and the model data for
that specific iteration.
Gameface solves this with data-binding event attributes . The syntax is data-bind-[eventName], where [eventName] is any
supported DOM event (like click, mouseenter, mousedown, etc.). The attribute’s value is a function call expression: you call an ordinary
JavaScript function in scope and pass the event, the element, and any bound values from the model or from a data-bind-for iterator.
Basic Syntax
Section titled “Basic Syntax”<div data-bind-click="selectPartyMember(event, this, {{member}})"> <!-- ... --></div>The three arguments available in a data-binding event expression are:
event: The standard JavaScript Event object from the fired event.this: A reference to the DOM element that the handler is registered on.{{member}}(or any model/iterator reference): The bound data for this element, resolved at the time the event fires.
Wiring Events to Data-Bound Elements
Section titled “Wiring Events to Data-Bound Elements”Let us add click handling to the party roster so that clicking a member card selects them. The repeat wrapper (.roster__member-entry) carries only
data-bind-for. The inner .roster__member card carries data-bind-click and references {{member}} as a descendant of that wrapper.
import Block from '@components/Layout/Block/Block';import TextBlock from '@components/Basic/TextBlock/TextBlock';import InlineTextBlock from '@components/Basic/InlineTextBlock/InlineTextBlock';
const RosterWithClick = () => ( <Block class="roster__list"> <Block class="roster__member-entry" attr:data-bind-for="member:{{PartyModel.members}}"> <Block class="roster__member" attr:data-bind-click="selectPartyMember(event, this, {{member}})" > <TextBlock class="roster__member-name" attr:data-bind-value="{{member.name}}" /> <InlineTextBlock class="roster__member-level"> Lv. <span data-bind-value="{{member.level}}" /> </InlineTextBlock> </Block> </Block> </Block>);<div class="roster__list"> <div class="roster__member-entry" data-bind-for="member:{{PartyModel.members}}"> <div class="roster__member" data-bind-click="selectPartyMember(event, this, {{member}})"> <span class="roster__member-name" data-bind-value="{{member.name}}"></span> <span class="roster__member-level"> Lv. <span data-bind-value="{{member.level}}"></span> </span> </div> </div></div>When the player clicks on a member card, the engine calls selectPartyMember with the click event, the clicked DOM element, and the full member
object for that iteration. No manual addEventListener setup is needed, and the binding survives array changes because the engine re-attaches
handlers automatically when it regenerates the list.
Here is what the mock implementation might look like:
function selectPartyMember(event, element, memberData) { // Update plain fields on the model (no methods on the synced object) PartyModel.selectedMemberName = memberData.name;
engine.updateWholeModel(PartyModel); engine.synchronizeModels();}
engine.whenReady.then(() => { engine.createJSModel("PartyModel", { members: [ { name: "Kira", role: "Scout", level: 28, hpPercent: 92, isLeader: true }, { name: "Thane", role: "Guardian", level: 31, hpPercent: 45, isLeader: false }, { name: "Lyra", role: "Medic", level: 26, hpPercent: 78, isLeader: false }, ], requiredSize: 4, isFull: false, selectedMemberName: "", });
engine.synchronizeModels();});Supported Events
Section titled “Supported Events”The data-bind-[eventName] syntax supports a wide range of DOM events. Here are the most commonly used categories:
Mouse events: click, dblclick, mousedown, mouseup, mouseover, mouseout, mouseenter, mouseleave, mousemove
Keyboard events: keydown, keyup, keypress
Focus events: focus, blur, focusin, focusout
Input events: input, change
Touch events: touchstart, touchend
Other events: scroll, wheel, resize, load, error, abort
Using Multiple Event Bindings
Section titled “Using Multiple Event Bindings”You can attach multiple event bindings to the same element. Each one is a separate attribute:
import Block from '@components/Layout/Block/Block';import TextBlock from '@components/Basic/TextBlock/TextBlock';
const RosterMultiEvent = () => ( <Block class="roster__list"> <Block class="roster__member-entry" attr:data-bind-for="member:{{PartyModel.members}}"> <Block class="roster__member" attr:data-bind-click="selectPartyMember(event, this, {{member}})" attr:data-bind-mouseenter="previewPartyMember(event, this, {{member}})" attr:data-bind-mouseleave="clearPartyPreview(event, this)" > <TextBlock attr:data-bind-value="{{member.name}}" /> </Block> </Block> </Block>);<div class="roster__list"> <div class="roster__member-entry" data-bind-for="member:{{PartyModel.members}}"> <div class="roster__member" data-bind-click="selectPartyMember(event, this, {{member}})" data-bind-mouseenter="previewPartyMember(event, this, {{member}})" data-bind-mouseleave="clearPartyPreview(event, this)"> <span data-bind-value="{{member.name}}"></span> </div> </div></div>This gives each member card three distinct interactions: a click to select, a hover-in to preview, and a hover-out to clear the preview. All three
handlers have access to the same member context from the loop.
function previewPartyMember(event, element, memberData) { // Update the PartyModel preview fields with the hovered member's data PartyModel.previewName = memberData.name; PartyModel.previewClass = memberData.heroClass; PartyModel.previewHealth = memberData.health; engine.updateWholeModel(PartyModel); engine.synchronizeModels();}
function clearPartyPreview(event, element) { // Clear preview fields when the mouse leaves PartyModel.previewName = ''; PartyModel.previewClass = ''; PartyModel.previewHealth = 0; engine.updateWholeModel(PartyModel); engine.synchronizeModels();}Complete party roster example
Section titled “Complete party roster example”Let us combine every concept from this article into a complete party roster panel. The component uses data-bind-if for conditional warnings,
data-bind-for to generate the member list, and data-bind-click for selection handling. It also incorporates data-bind-value,
data-bind-style-width, and data-bind-class-toggle from the
previous article.
Here is the full HTML template:
import Block from '@components/Layout/Block/Block';import TextBlock from '@components/Basic/TextBlock/TextBlock';import InlineTextBlock from '@components/Basic/InlineTextBlock/InlineTextBlock';import Button from '@components/Basic/Button/Button';
const PartyRoster = () => ( <Block class="roster"> <div class="roster__title">Squad Roster</div>
<InlineTextBlock class="roster__warning" attr:data-bind-if="{{PartyModel.members}}.length < {{PartyModel.requiredSize}}" > Roster incomplete! Need{' '} <span data-bind-value="{{PartyModel.requiredSize}} - {{PartyModel.members}}.length" /> more to deploy. </InlineTextBlock>
<Block class="roster__list"> <Block class="roster__member-entry" attr:data-bind-for="member:{{PartyModel.members}}"> <Block class="roster__member" attr:data-bind-click="selectPartyMember(event, this, {{member}})" attr:data-bind-class-toggle="roster__member--selected: {{member.name}} === {{PartyModel.selectedMemberName}}" > <Block class="roster__member-header"> <TextBlock class="roster__member-name" attr:data-bind-value="{{member.name}}" /> <TextBlock class="roster__leader-badge" attr:data-bind-if="{{member.isLeader}}"> Leader </TextBlock> <InlineTextBlock class="roster__member-level"> Lv. <span data-bind-value="{{member.level}}" /> </InlineTextBlock> </Block> <TextBlock class="roster__member-role" attr:data-bind-value="{{member.role}}" /> <Block class="roster__hp-track"> <Block class="roster__hp-fill" attr:data-bind-style-width="{{member.hpPercent}} + '%'" attr:data-bind-class-toggle="roster__hp-fill--low: {{member.hpPercent}} < 30" /> </Block> </Block> </Block> </Block>
<Button class="roster__deploy-btn" attr:data-bind-if="{{PartyModel.members}}.length >= {{PartyModel.requiredSize}}" attr:data-bind-click="deployPartySquad(event)" > Deploy Squad </Button> </Block>);
export default PartyRoster;<div class="roster"> <h2 class="roster__title">Squad Roster</h2>
<!-- Conditional: Only visible when the party is not full --> <p cohinline class="roster__warning" data-bind-if="{{PartyModel.members}}.length < {{PartyModel.requiredSize}}"> Roster incomplete! Need <span data-bind-value="{{PartyModel.requiredSize}} - {{PartyModel.members}}.length"></span> more to deploy. </p>
<!-- The member list, generated from the array --> <div class="roster__list"> <div class="roster__member-entry" data-bind-for="member:{{PartyModel.members}}"> <div class="roster__member" data-bind-click="selectPartyMember(event, this, {{member}})" data-bind-class-toggle="roster__member--selected: {{member.name}} === {{PartyModel.selectedMemberName}}"> <div class="roster__member-header"> <span class="roster__member-name" data-bind-value="{{member.name}}"></span> <span class="roster__leader-badge" data-bind-if="{{member.isLeader}}">Leader</span> <span class="roster__member-level"> Lv. <span data-bind-value="{{member.level}}"></span> </span> </div>
<div class="roster__member-role" data-bind-value="{{member.role}}"></div>
<div class="roster__hp-track"> <div class="roster__hp-fill" data-bind-style-width="{{member.hpPercent}} + '%'" data-bind-class-toggle="roster__hp-fill--low: {{member.hpPercent}} < 30"></div> </div> </div> </div> </div>
<!-- Conditional: Only visible when the party is full --> <div class="roster__deploy-btn" data-bind-if="{{PartyModel.members}}.length >= {{PartyModel.requiredSize}}" data-bind-click="deployPartySquad(event)"> Deploy Squad </div></div>The CSS that styles the roster panel.
.roster { width: 320px; background-color: rgba(15, 15, 20, 0.92); border: 1px solid #2a2a35; border-top: 3px solid #00DCFF; padding: 16px; font-family: 'Barlow Semi Condensed', sans-serif; color: #cccccc;}
.roster__title { font-size: 1.1rem; font-weight: bold; color: #ffffff; text-transform: uppercase; letter-spacing: 2px; margin-bottom: 12px;}
.roster__warning { background-color: rgba(255, 180, 0, 0.12); border-left: 3px solid #F5BC35; padding: 8px 12px; margin-bottom: 12px; font-size: 0.85rem; color: #F5BC35;}
.roster__list { display: flex; flex-direction: column;}
/* Repeat wrapper: only carries data-bind-for; spacing lives here so layout matches a flat member list */.roster__member-entry { margin-bottom: 6px;}
.roster__member { padding: 10px 12px; border: 1px solid #2a2a35; cursor: pointer; transition: border-color 0.15s ease;}
.roster__member:hover { border-color: #00DCFF;}
.roster__member--selected { border-color: #00DCFF; background-color: rgba(0, 220, 255, 0.08);}
.roster__member-header { display: flex; align-items: center; margin-bottom: 4px;}
.roster__member-name { font-size: 1rem; font-weight: bold; color: #ffffff; margin-right: 8px;}
.roster__leader-badge { font-size: 0.7rem; font-weight: bold; color: #F5BC35; text-transform: uppercase; border: 1px solid #F5BC35; padding: 1px 5px; margin-right: auto;}
.roster__member-level { font-size: 0.8rem; color: #00DCFF; margin-left: auto;}
.roster__member-role { font-size: 0.8rem; color: #888888; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 6px;}
.roster__hp-track { width: 100%; height: 5px; background-color: #1a1a22; overflow: hidden;}
.roster__hp-fill { height: 100%; background-color: #00DCFF; transition: width 0.15s linear;}
.roster__hp-fill--low { background-color: #ff2222;}
.roster__deploy-btn { margin-top: 12px; padding: 10px; text-align: center; font-weight: bold; text-transform: uppercase; letter-spacing: 1px; color: #0a0a0a; background-color: #00DCFF; cursor: pointer; transition: opacity 0.15s ease;}
.roster__deploy-btn:hover { opacity: 0.85;}And the JavaScript that creates the mock model, with handlers defined outside the model object:
function selectPartyMember(event, element, memberData) { PartyModel.selectedMemberName = memberData.name; engine.updateWholeModel(PartyModel); engine.synchronizeModels();}
function deployPartySquad(event) { console.log("Deploying squad!");}
engine.whenReady.then(() => { engine.createJSModel("PartyModel", { requiredSize: 4, selectedMemberName: "", members: [ { name: "Kira", role: "Scout", level: 28, hpPercent: 92, isLeader: true }, { name: "Thane", role: "Guardian", level: 31, hpPercent: 45, isLeader: false }, { name: "Lyra", role: "Medic", level: 26, hpPercent: 78, isLeader: false }, ], });
engine.synchronizeModels();});This panel combines structural and visual bindings:
data-bind-ifshows the roster warning and deploy control based onmembers.lengthvsrequiredSize.data-bind-foranddata-bind-clickbuild the member list and route clicks with{{member}}context.data-bind-value,data-bind-style-width, anddata-bind-class-toggledrive text and HP styling inside each card.
The entire panel is declarative. Manipulating the members array in the model will automatically update the UI, adding a fourth member to the
members array would automatically generate a new card, hide the warning, and reveal the deploy button.
© 2026 Coherent Labs. All rights reserved.