Skip to main content

HTML Attributes

Tag your web components using data-elb attributes to enable structured event tracking without custom JavaScript.

By adding a few simple attributes to your markup, you can track user behavior such as clicks, views, form submissions, and more.

This technique is especially helpful for component libraries and design systems, where it is important to scale event tagging in a maintainable, reusable way. You can bake tracking into components once, then use them everywhere, without extra code or tracking configuration.

You'll learn how to:

  • Define entities, actions, and triggers directly in your HTML
  • Add contextual and global properties
  • Handle dynamic values, arrays, and type casting
  • Link elements across DOM boundaries
  • Structure nested data automatically

If you're looking for a lightweight, declarative way to implement analytics and event tracking, data-elb tagging is your foundation.

Concept

Tag a page...

<!-- Generic usage -->
<div
data-elb="ENTITY"
data-elb-ENTITY="KEY:VALUE"
data-elbaction="TRIGGER:ACTION"   <!-- nearest entity only -->
data-elbactions="TRIGGER:ACTION"  <!-- all entities -->
data-elbcontext="KEY:VALUE"
data-elbglobals="KEY:VALUE"
/>

<!-- Example usage -->
<div data-elbglobals="language:en">
<div data-elbcontext="test:engagement">
  <div data-elb="promotion" data-elbaction="visible:view">
    <h1 data-elb-promotion="name:Setting up tracking easily">
      Setting up tracking easily
    </h1>
    <p data-elb-promotion="category:analytics">Analytics</p>
  </div>
</div>
</div>

... to get a structured event as a result:

{
name: 'promotion view', // Name as a combination of entity and action
data: {
// Arbitrary properties related to the entity
name: 'Setting up tracking easily',
category: 'analytics',
},
context: {
// Provides additional information about the state during the event
test: ['engagement', 0] // Key, [value, order]
},
globals: {
// General properties that apply to every event
language: 'en'
},
custom: {}, // Additional space for individual setups
user: {
// Contains user identifiers for different identification levels
// Require consent and set manually for sessions building and cross-device
id: 'us3r1d',
device: 'c00k131d',
session: 's3ss10n1d',
},
nested: [], // List of nested entities
consent: { functional: true }, // Status of the granted consent state(s)
id: '0123456789abcdef', // W3C Trace Context span_id (16 hex chars)
trigger: 'visible', // Name of the trigger that fired
entity: 'promotion', // The entity name involved in the event
action: 'view', // The specific action performed on the entity
timestamp: 1647261462000, // Time when the event fired
timing: 3.14, // Duration how long it took to trigger this event
source: {
// Details about the origin of the event
type: 'browser', // Source kind (e.g. browser, dataLayer)
platform: 'web', // Runtime platform (web or server)
schema: '4', // walkerOS event schema version
url: 'https://github.com/elbwalker/walkerOS', // Page URL
referrer: 'https://www.walkeros.io/' // Referrer URL
}
}
Updating from v3

The top-level group, count, and version fields are gone. The id is now a W3C Trace Context span_id (16 lowercase hex chars). Inside source, type is now the source kind (browser, dataLayer, ...), the runtime is captured by the new platform field, the previous id is now url, and previous_id is now referrer.

note

You are entirely free to define naming conventions.

Entity and action

You define the entity scope by setting the data-elb attribute with the name of an entity to an element, e.g. data-elb="promotion". The default entity is page when no data-elb is set.

An action can be added by setting one of the following attributes on the same level or child elements in combination with a matching trigger:

  • data-elbaction - applies action to the nearest entity only
  • data-elbactions - applies action to all entities in the DOM hierarchy

Both attributes use the same syntax, e.g., data-elbaction="visible:view" or data-elbactions="click:select" to fire events when triggered.

Migration Note

The behavior of data-elbaction changed in @walkeros to apply to nearest entity only. For the previous @elbwalker "all entities" behavior, use data-elbactions. See the Migration Guide for details.

To define the entities' properties, set the composited attribute data-elb-ENTITY with the key and value, e.g. data-elb-promotion="name:tagging is fun;position:overlay".

Triggers

There is a bunch of pre-built triggers. You don't have to deal with event listener or mutation observer initialization.

TriggerDefinition
loadafter loading a page when DOM is ready
clickwhen an element or a child is clicked
impressionafter an element has been in the viewport for at least 50% for one second
visibleeach time an element re-enters the viewport after being out of view
hovereach time the mouse enters the corresponding element
submiton valid form submission
wait(ms)waits ms seconds (15 seconds by default) until triggering
pulse(ms)recurring trigger every ms seconds (15 seconds by default) if the page is not hidden
note

Trigger names are predefined and to be selected from the list, while the action can be an arbitrarily defined name.

Abbreviation

If the trigger and action values are equal, e.g. for click events, you can shorten the implementation:

<b data-elbaction="click">
Use the short version, instead of
<s data-elbaction="click:click">long</s>
</b>

Parameters

Some triggers require more information during initialization, while others accept optional parameters. The scroll trigger needs to know the percentage a user scrolls down, a wait trigger the number of milliseconds until the action gets triggered. Use brackets behind the trigger to pass that information.

<!-- specifying trigger parameters -->
<p data-elbaction="wait(10):interested"></p>
<p data-elbaction="pulse(10):interested"></p>

Action filter

At some point, you might want to nest one entity inside another. To prevent an action to trigger unwanted entities, you can restrict the action to a specific entity by adding the name, e.g. data-elbaction="load:view(product) or data-elbactions="load:view(product)". If the trigger event gets called, the result will only include the property values from the specific entities.

<!-- setting a filter for an entity -->
<div data-elb="foo">
<div data-elb="bar" data-elbaction="load:hello(bar)">
  only the bar hello event fires.
</div>
</div>

Click trigger

When you click an element, the browser source reads the click in the capture phase, so it resolves the data-elbaction and the surrounding entity from the DOM as it exists at click time, before your app's own click handlers run. If it finds a data-elbaction with the click trigger on the clicked element or any of its parents, it fires the action. Often the image or a whole div inside a button gets clicked, not the button itself; the parent walk still resolves the action and entity for you.

<button data-elb="product" data-elbaction="click">
<img class="full" src="some.jpg" alt="" />
</button>
info

Reading in the capture phase means stopPropagation in your app or a third-party widget no longer prevents a tagged click from being captured, and the entity is read before a click-driven re-render can unmount it (a common cause of events falling back to page in single-page apps). To restore the previous bubbling behavior, set capture: false on the browser source.

Linking elements

Use the data-elblink tag to extend the scope of an entity by elements placed somewhere else (like modals). Specific IDs connect linked elements. They are hierarchically and can either be a parent or a child.

<div data-elb="info" data-elblink="details:parent">...</div>
...
<div data-elblink="details:child" data-elbaction="visible">...</div>
<p data-elblink="another:child">...</p>

The second element is the parent, triggering the visible action for the info visible event. There can be multiple children, but there is only one parent element per ID.

note

data-elb, data-elbaction, data-elbactions, data-elbcontext, data-elbglobals, and data-elblink are reserved attributes, whereas data-elb-* attributes may be arbitrary combinations based on the related entity name. data-elb_ is a reserved path-scoped generic that uses the same value syntax as data-elb- but only reaches triggers nested below it. Actions and properties can be set anywhere inside an elb scope.

warning

Spaces in entities, e.g., "shopping cart" or actions, e.g., "add to cart" will be replaced by underscores to "shopping_cart" and "add_to_cart".

tip

Spaces in property values are no problem, e.g. "category: 'summer sale'" works fine. But it is better to set them in quotes when doing so or when using symbols, especially : or ;

Data

Basic attributes

To specify data, use the name of the entity. The data attributes have to be inside of the entity scope or a parent.

<div data-elb-entity="source:parent">
<div data-elb="entity">
  <p data-elb-entity="key:value">...</p>
  <p data-elb-entity="foo:bar">...</p>
</div>
</div>
{ data: { source: "parent", key: "value", foo: "bar", } }

Hierarchy

There is a hierarchy for the data properties, where the order defines which values to use for similar keys. Based on the triggering action element, the closest ones or parent values will be preferred over the others.

<div id="family" data-elb="e" data-elbaction="click">
<div id="parent" data-elb-e="key:foo" data-elbaction="click">
  <p id="child" data-elb-e="key:bar" data-elbaction="click"></p>
  <b id="sibling" data-elbaction="click"></b>
</div>
<b data-elb-e="key:baz"></b>
</div>

Based on which element gets clicked, the event will contain the following data

  • family: { key: 'baz' }, the last found data-property
  • parent: { key: 'foo' }, a direct data-value
  • child: { key: 'bar' }, direct value closer than the parent
  • sibling: { key: 'foo' }, no value specified, so it takes the parent's value

Type casting

Property values will be cast to their type, supporting string, number & boolean.

<div data-elb="types">
<p data-elb-types="string:text">{ string: "text" }</p>
<p data-elb-types="int:42;float:3.14">{ int: 42, float: 3.14 }</p>
<p data-elb-types="bool:true">{ bool: true }</p>
</div>

Multiple attributes

Browsers override duplicate attributes. Hence an element can only have one data-elb, data-elb-ENTITY, and/or data-elbaction attribute at a time. Nevertheless, it’s possible to define multiple entities, properties, and/or actions within one attribute using quotes and semicolons. A semicolon splits key-value pairs. Therefore, it’s necessary to escape values that contain a semicolon. Quotes are here to meet your needs. To prevent a mistaken value-split, use single quotes.

<!-- value wrapping with quotes -->
<p data-elb="foo" data-elb-foo="b:a;r">{ "b": "a", "r": true }</p>
<p data-elb="foo" data-elb-foo="b:'a;r'">{ "b": "a;r" }</p>

If a single quote is part of the value, escape it with a backslash:

<!-- escaping values with backslash -->
<p data-elb="foo" data-elb-foo="bar:it's escaped">{ "bar": "it's escaped" }</p>

The semicolon can be used as a separator to list multiple values inside of a data-elb or data-elbaction attribute.

<!-- using multiple key-value pairs at once -->
<p data-elb="foo" data-elb-foo="a:1;b:2">{ "a": 1, "b": 2 }</p>

Dynamic field values

You might want to measure dynamic field values, e.g. the quantity of a product or the value of the selected element. Use a # at the beginning, followed by the attribute name to access the value of the element attribute.

<!-- Basic usage: elb-ENTITY="KEY:#VALUE" -->
<input type="text" value="blue" data-elb-product="color:#value"></input>
<div data-elb-product="name:#innerHTML">Everyday Ruck Snack</div>

To capture a selected option from a list, use elb-ENTITY="KEY:#selected" to get size:20L

<select data-elb-product="size:#selected">
<option value="18L">18L</option>
<option value="20L" selected="selected">20L</option>
</select>

Arrays

To use array types, add the [] suffix to a property's name, such as size[]:m. It will generate de-duplicated data properties.

<div data-elb="product">
<p data-elb-product="size[]:s;size[]:l"></p>
<p data-elb-product="size[]:l"></p>
</div>
{
data: {
size: ["s", "l"],
},
// ...
}

Generic properties

Leave the entity name empty (only data-elb-) to add the property to any related entity. Explicitly named properties are preferred over generic ones.

<div data-elb-="p:v">
<div data-elb="generic">
  <p data-elb-generic="k:v"></p>
  <p data-elb-="g:v"></p>
  <p data-elb-generic="o:v"></p>
  <p data-elb-="o:x"></p>
</div>
</div>
note

Explicit properties are preferred over generic ones.

{
data: {
p: 'v', // parent
k: 'v', // explicit
g: 'v', // generic
o: 'v' // overridden by explicit
},
// ...
}

Scoped generic properties

The blanket data-elb- generic is added to every trigger inside its entity, because the entity collects it through a descendant search. Use data-elb_ (trailing underscore, no dash) when a property should reach only the triggers nested below it. It carries the same key:value payload as data-elb- (including [] arrays and #dynamic values), but it is collected only while bubbling up from the triggered element, so sibling triggers outside its branch never receive it.

<div data-elb="product" data-elb-product="name:A">
<div data-elb_="size:L">
  <button data-elbaction="click">L</button>
</div>
<div data-elb-="color:red"></div>
<button data-elbaction="click">Plain</button>
</div>

Clicking the first button bubbles up through the data-elb_ element, so it receives size:L along with the blanket color:red. Clicking the second button never passes through that element, so it has no size.

// first button
{ data: { name: 'A', color: 'red', size: 'L' } }
// second button
{ data: { name: 'A', color: 'red' } }

A scoped value on an element closer to the trigger wins over a value set farther up the tree, including an explicit entity property on a higher element.

Globals

There might be properties that don't belong to just one event but to all events on a page. Those properties are called globals, and are read from the DOM for every event. Globals are arbitrary, like the data property. What is unique about them is that you can define them anywhere on a page using the data-elbglobals attribute.

<div data-elbglobals="outof:scope"></div>

<div data-elb="entity" data-elb-entity="foo:bar" data-elbaction="load:action" />

This example will lead to the following event:

{
"event": "entity action",
"data": { "foo": "bar" },
"globals": { "outof": "scope" }
// other properties omitted
}
info

Globals are collected fresh from the DOM on every event, so a data-elbglobals value changed between events is reflected in the next one.

User

data-elbuser sets user identity that is applied to the page view and every event after it. Unlike globals, which are re-read from the DOM for every event, the user is collected once per run, right before the page view, and stored as persistent collector state, the same state the walker user command writes to. Tag any element with the identifiers you have available:

<div data-elbuser="id:u123;loggedin:true"></div>

The page view, and every event pushed after it, carries the user:

{
"event": "page view",
"user": { "id": "u123", "loggedin": true }
// other properties omitted
}

Because the user is collected once, right before the page view, only the page view and later events carry it. Events that already fired earlier in the run, such as window.elbLayer pushes replayed before the page view, are not retroactively stamped. If multiple elements carry data-elbuser, their values are merged (last one in the DOM wins per key). An absent data-elbuser attribute leaves any existing user untouched; it never wipes a user set another way, such as by an earlier walker user call.

warning

Use fully anonymized & arbitrary IDs by default and check your options with persistent user IDs with your data protection officer.

Context

Context doesn't work like globals for every event, but is helpful information for every framing context an event is embedded in. A context could be a position, a test, or specific components for example.

<div data-elbcontext="test:engagement" data-elbglobals="plan:paid">
<div data-elbcontext="recommendation:smart_ai">
  <div
    data-elb="promotion"
    data-elbaction="click"
    data-elb-promotion="title:click me"
  >
    click me
  </div>
</div>
</div>

The context properties are tuples with the value and an index, starting at the closest parent ([value, index]). Access them via event.context.key[0].

{
event: "promotion click",
data: { title: "click me" },
globals: { plan: "paid" },
context: {
test: ["engagement", 1],
recommendation: ["smart_ai", 0],
},
// other properties omitted
}
tip

At elbwalker we often use context for predefined journeys and stages to measure events along a specific user journey in structured way.

Nested entities

A data-elb entity within another data-elb entity is called a nested entity.

The walker algorithm runs through nested entities and treats them like regular entities by gathering all related information. Nested entities are accessible in the nested array of each event. Each element is a regular entity.

<div
data-elb="mother"
data-elb-mother="label:caring"
data-elbaction="load:view"
>
<div data-elb="son" data-elb-son="age:23"></div>
<div data-elb="daughter" data-elb-daughter="age:32">
  <div data-elb="baby" data-elb-baby="status:infant"></div>
</div>
</div>

This example will lead to the following event on load:

{
"event": "mother view",
"data": { "label": "caring" },
"nested": [
{ "entity": "son", "data": { "age": 23 } },
{
"entity": "daughter",
"data": { "age": 32 },
"nested": [{ "entity": "baby", "data": { "status": "infant" } }],
},
{ "entity": "baby", "data": { "status": "infant" } },
],
// other properties omitted
}

Nested entities that are nested inside another entity will be captured on both levels.

note

Nested entities are not available for auto-captured page view events.

tip

Learn more about the walkerOS event model in our documentation.

Shadow DOM

walkerOS tags elements inside shadow DOM. Entity and context resolution reaches upward through the shadow boundary: an element inside a shadow root resolves its data-elb entity and data-elbcontext from light-DOM ancestors above the host, for both open and closed roots.

One limit applies to every shadow root: data-elbglobals is not collected inside shadow DOM, so define globals in the light DOM.

Open shadow roots

Elements inside mode: 'open' shadow roots are discovered and tracked automatically. Properties and context are collected, clicks and form submissions resolve to the inner element, and the visible and impression triggers fire when the element enters the viewport. Scroll depth is measured against the viewport, so it stays correct for elements nested inside a shadow root.

Closed shadow roots

A closed shadow root is invisible from its host (host.shadowRoot is null), so the page scan cannot reach into it. To track a closed subtree, pass the root reference returned by attachShadow to walker init:

const root = host.attachShadow({ mode: 'closed' });
// render your tagged markup into root
elb('walker init', root);

Its elements then behave like any other tagged markup: load triggers fire on the scan, and visible and impression fire on viewport entry. Two triggers still cannot reach inside a closed root:

  • click and submit resolve through the event's composed path, which stops at the host, so an interaction inside a closed root is attributed to the host rather than the inner element.
  • automatic discovery never reaches a closed root, only the explicit walker init reference does.

Auto-init with data-elbobserve

In a single-page app, tagged content often appears after the initial page scan: a route renders a new view, a list loads lazily, a chat streams in messages. The normal way to track that content is to call walker init on its container after each injection. Mark the container with data-elbobserve instead, and the browser source watches it: any tagged content injected into it is registered for tracking automatically, and its triggers are cleaned up when the content is removed. You no longer call walker init per injection.

Statically present tagged content inside the container is still registered by the normal page scan. data-elbobserve only changes how future injections are handled.

<!-- Mark the container that will receive injected content -->
<div data-elbobserve>
<!-- Tagged content a SPA injects here is auto-registered -->
</div>

When your app renders tagged markup into that container, no follow-up call is needed:

// The SPA renders tagged markup into the observed container.
// No walker init call is required afterwards.
container.innerHTML = `
<div data-elb="product" data-elbaction="visible:view">
  <h2 data-elb-product="name:Everyday Ruck Snack">Everyday Ruck Snack</h2>
</div>
`;

data-elbobserve is a presence-only attribute: its presence marks the container and it takes no value.

Open shadow roots

A data-elbobserve container inside an open shadow root is watched by its own observer, so tagged content injected into that shadow root is auto-registered the same way. A closed shadow root cannot be discovered, so keep using an explicit walker init with the root reference for closed roots (see Shadow DOM above).

Scope it tightly

Put data-elbobserve on the smallest container that wraps the injected content, never on the app root. The observer watches the whole subtree below the marked container, so a tight scope keeps it cheap and avoids re-processing unrelated DOM.

Limitations

  • Node recycling and virtualization: a framework that reuses the same element instance for new content is skipped, because that element is already registered and this version does not watch attribute changes. A changed action on a recycled node is missed. For virtualized lists that recycle nodes, keep using explicit walker init, or ensure the framework creates fresh nodes.
  • Overlapping scopes: do not place a walker init <element> sub-scope observe container over the same subtree as a document-scope observe container. Injected content stays safe (an already-registered element is not registered twice), but the adds are re-processed redundantly.
  • SVG content: only HTML elements are auto-registered. An SVG element carrying data-elbaction inside an observed container is not picked up, the same as with the static page scan.
  • Closed shadow roots: not reachable by discovery, use explicit walker init with the root reference.

Not in this version

These are deferred and may arrive later:

  • Attribute-watching: reacting to a data-elbaction added to an already-present element.
  • A throttle option for high-frequency mutations.
  • A function-callback parameter on the attribute.
💡 Need implementation support?
elbwalker offers hands-on support: setup review, measurement planning, destination mapping, and live troubleshooting. Book a 2-hour session (€399)