> Part of the walkerOS documentation. Project overview and full index: <https://www.walkeros.io/llms.txt>

# 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[​](#concept "Direct link to 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](https://www.w3.org/TR/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[​](#entity-and-action "Direct link to 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](https://www.walkeros.io/docs/guides/migration.md#data-elbaction-vs-data-elbactions) 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[​](#triggers "Direct link to Triggers")

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

| **Trigger** | **Definition**                                                                       |
| ----------- | ------------------------------------------------------------------------------------ |
| load        | after loading a page when DOM is ready                                               |
| click       | when an element or a child is clicked                                                |
| impression  | once, after the element has been on screen for one continuous second                 |
| visible     | like impression, but re-arms and fires again each time the element re-qualifies      |
| hover       | each time the mouse enters the corresponding element                                 |
| submit      | on 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.

### Impression and visible triggers[​](#impression-and-visible-triggers "Direct link to Impression and visible triggers")

`impression` fires once, and `visible` fires every time, after an element has been on screen for one continuous second in a foreground tab. "On screen" means at least half the element, or half the viewport, whichever is smaller, along each axis. A 716px card on a 450px-tall viewport therefore qualifies once about 225px of it is showing, rather than the 358px the previous rule required: an element bigger than the viewport no longer needs to cover almost the entire screen to count. The exact point is sampled rather than measured continuously, so it can land within about 1% of the element's height either side of that mark. `visible` re-arms only once the element drops back below that bar and then requalifies, so it can fire repeatedly as a user scrolls past it and back.

note

Extremely tall elements, beyond roughly 100x the viewport height, are not detected. Such an element never occupies enough of its own area to register, however far it is scrolled.

### Abbreviation[​](#abbreviation "Direct link to 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[​](#parameters "Direct link to 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[​](#action-filter "Direct link to 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[​](#click-trigger "Direct link to 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[​](#linking-elements "Direct link to 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[​](#data "Direct link to Data")

### Basic attributes[​](#basic-attributes "Direct link to 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[​](#hierarchy "Direct link to 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[​](#type-casting "Direct link to 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[​](#multiple-attributes "Direct link to 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[​](#dynamic-field-values "Direct link to 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[​](#arrays "Direct link to 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[​](#generic-properties "Direct link to 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[​](#scoped-generic-properties "Direct link to 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[​](#globals "Direct link to 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 for every event, so a `data-elbglobals` value changed between events is reflected in the next one. They are page level: the scan always covers the source's own scope (the whole document by default), so `elb('walker init', element)` narrows which elements get wired without narrowing the globals those elements report.

## User[​](#user "Direct link to 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](https://www.walkeros.io/docs/collector/commands.md#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 "Direct link to 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[​](#nested-entities "Direct link to 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[​](#shadow-dom "Direct link to 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[​](#open-shadow-roots "Direct link to 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[​](#closed-shadow-roots "Direct link to 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[​](#auto-init-with-data-elbobserve "Direct link to 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[​](#open-shadow-roots-1 "Direct link to 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](#shadow-dom) above).

### Scope it tightly[​](#scope-it-tightly "Direct link to 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[​](#limitations "Direct link to 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[​](#not-in-this-version "Direct link to 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.
