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

# HTML Attributes

With `data-elb` attributes you can track clicks, views, form submissions, and other user behavior straight from your markup, without writing JavaScript.

This works well for component libraries and design systems. Tag a component once, and every page that uses it is tracked without extra code or tracking configuration.

This page covers 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

## 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, click and submit only -->
  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)
url: 'https://github.com/elbwalker/walkerOS', // Page URL
referrer: 'https://www.walkeros.io/', // Referrer URL
count: 1, // Emission sequence within the run
trace: '0123456789abcdef0123456789abcdef', // Run-scoped W3C trace_id (32 hex chars)
release: { web: '3' } // Config release per flow, keyed by flow name
}
}
```

Updating from v3

The top-level `group`, `count`, and `version` fields moved into `source`: `source.trace` groups all events of a run, `source.count` is the emission sequence within the run, and `source.version` is an optional version a source can set. `source.release` records the release of each flow. 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 v3 `id` is now `url`, and `previous_id` is now `referrer`. See the [event model](/preview/pr-720/docs/getting-started/event-model.md) for the full structure.

note

You can choose your own naming conventions.

## Entity and action[​](#entity-and-action "Direct link to Entity and action")

The `data-elb` attribute sets the entity scope of an element, e.g. `data-elb="promotion"`. Without a `data-elb`, the entity is `page`.

To add an action, set one of the following attributes on the same element or on a child element, together with a matching trigger:

* `data-elbaction` applies the action to the nearest entity only.
* `data-elbactions` applies the action to the nearest entity and every entity above it in the DOM hierarchy. It works for the `click` and `submit` triggers only. For every other trigger, use `data-elbaction`.

Both attributes use the same syntax, e.g. `data-elbaction="visible:view"` or `data-elbactions="click:select"`.

Migration Note

In @walkeros, `data-elbaction` applies to the nearest entity only. The @elbwalker packages applied it to all entities. To keep that behavior for `click` and `submit`, use `data-elbactions`. See the [Migration Guide](/preview/pr-720/docs/guides/migration.md#data-elbaction-vs-data-elbactions) for details.

To define the entity's properties, set the composite attribute `data-elb-ENTITY` with keys and values, e.g. `data-elb-promotion="name:tagging is fun;position:overlay"`.

## Triggers[​](#triggers "Direct link to Triggers")

The browser source comes with built-in triggers, so you don't set up event listeners or mutation observers yourself.

| **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                                                                      |
| scroll(depth) | once, when `depth` percent of the element (50 by default) is above the bottom of the viewport |
| wait(ms)      | once, after `ms` milliseconds (15000 by default)                                              |
| pulse(ms)     | every `ms` milliseconds (15000 by default) while the page is not hidden                       |

note

Trigger names come from this list. Action names are up to you.

### 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, so an element taller than the viewport does not need to fill the screen to count. The source samples this point instead of measuring it continuously, so it can land within about 1% of the element's height on either side of that mark. `visible` re-arms only after the element drops below that bar and then qualifies again, 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 shows enough of its own area to register, however far it is scrolled.

### Abbreviation[​](#abbreviation "Direct link to Abbreviation")

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

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

### Parameters[​](#parameters "Direct link to Parameters")

The `scroll`, `wait`, and `pulse` triggers accept a parameter in brackets after the trigger name. `scroll` takes a percentage from 0 to 100 (default 50) and ignores values outside that range. `wait` and `pulse` take a number of milliseconds (default 15000).

```
<!-- specifying trigger parameters -->
<p data-elbaction="scroll(75):read"></p>
<p data-elbaction="wait(10000):interested"></p>
<p data-elbaction="pulse(10000):interested"></p>
```

### Action filter[​](#action-filter "Direct link to Action filter")

When one entity is nested inside another, you can restrict an action to a specific entity by adding its name in brackets after the action. With `data-elbactions`, e.g. `data-elbactions="click:select(product)"`, only entities with that name fire the action. With `data-elbaction`, e.g. `data-elbaction="load:view(product)"`, the action goes to the nearest entity with that name.

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

<div data-elb="foo">
  <div data-elb="bar" data-elbaction="load:hello(foo)">
    the foo hello event fires, bar is skipped.
  </div>
</div>
```

### Click trigger[​](#click-trigger "Direct link to Click trigger")

The browser source reads clicks in the capture phase. 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` or `data-elbactions` with the `click` trigger on the clicked element or any of its parents, it fires the action. Users often click an image or a div inside a button rather than the button itself, and the walk up the parents still resolves the action and entity.

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

info

Because clicks are read in the capture phase, `stopPropagation` in your app or a third-party widget does not prevent a tagged click from being captured. The entity is also read before a click-driven re-render can unmount it, a common cause of events falling back to `page` in single-page apps. To read clicks in the bubbling phase instead, set `capture: false` on the browser source.

## Linking elements[​](#linking-elements "Direct link to Linking elements")

Use the `data-elblink` attribute to extend the scope of an entity to elements placed somewhere else, such as modals. A shared ID connects the linked elements, and each element is either the 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 first element is the parent. The second element is its child and carries the visible action, which fires the `info visible` event. An ID can have multiple children but only one parent element.

note

`data-elb`, `data-elbaction`, `data-elbactions`, `data-elbcontext`, `data-elbglobals`, `data-elbuser`, `data-elblink`, and `data-elbobserve` 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

An entity name consists of letters, digits, `_`, and `-`. Non-ASCII letters are allowed too. A `data-elb` value with any other character, such as `shopping cart`, is ignored: the element counts as untagged, and an action inside it goes to the next entity above it, or to `page`. Use `shopping_cart` instead.

Follow the same rule for action names, e.g. `add_to_cart`. Mapping rules and `elb('entity action')` read entity and action from the event name split at the first space, so `add to cart` would be matched as `add`.

tip

Spaces in property values work, e.g. `data-elb-product="category:summer sale"`. Colons inside a value work too, because only the first colon separates key and value. Wrap a value in single quotes when it contains a semicolon.

## Data[​](#data "Direct link to Data")

### Basic attributes[​](#basic-attributes "Direct link to Basic attributes")

To set `data`, use the name of the entity. The `data` attributes have to be inside the entity scope or on 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")

When several elements set the same key, the position of the triggering element decides which value wins. Values on the element itself or its parents are 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>
```

Depending on which element gets clicked, the event contains 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 are cast to their type: string, number, or 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")

HTML keeps only one attribute of each name per element, so an element can have one `data-elb`, one `data-elb-ENTITY`, and one `data-elbaction` attribute at a time. A single `data-elb-ENTITY` attribute can still hold several properties, and a single `data-elbaction` several actions, separated by semicolons.

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

Since a semicolon splits key-value pairs, wrap a value that contains a semicolon in single quotes. A key without a value, like `r` in the first line below, gets an empty string.

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

A single quote cannot be part of a value, and backslash escaping is not supported. Double quotes are kept as written, so write the attribute itself in single quotes when a value needs them:

```
<p data-elb="foo" data-elb-foo='quote:say "hi"'>{ quote: 'say "hi"' }</p>
```

### Dynamic field values[​](#dynamic-field-values "Direct link to Dynamic field values")

To measure dynamic field values, e.g. the quantity of a product or the value of a form field, start the value with `#` followed by a property name of the element. The source then reads that property from the element.

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

To capture the selected option of a list, use `data-elb-ENTITY="KEY:#selected"`. It reads the text of the selected option, here `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 create an array, add the `[]` suffix to a property's name, such as `size[]:m`. Duplicate values are removed.

```
<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>
```

```
{
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 reaches 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 the source collects it only while walking 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 walks up through the `data-elb_` element, so the event gets `size:L` along with the blanket `color:red`. The walk from the second button never passes that element, so its event 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")

Globals are properties that apply to all events on a page. The source reads them from the DOM for every event. Like data properties, they are arbitrary, but you can define them anywhere on a page with 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 leads to the following event:

```
{
"name": "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 shows up 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 for the page view and every event after it. Globals are re-read from the DOM for every event, while the user is collected once per run, right before the page view, and stored as persistent collector state. That is the same state the [walker user](/preview/pr-720/docs/collector/commands.md#user) command writes to. Tag any element with the identifiers you have:

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

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

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

Events that fired earlier in the run, such as `window.elbLayer` pushes replayed before the page view, do not get the user added afterwards. If multiple elements carry `data-elbuser`, their values are merged, and the last one in the DOM wins per key. Without a `data-elbuser` attribute, the existing user stays as it is, including a user set by an earlier `walker user` call.

warning

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

## Context[​](#context "Direct link to Context")

Context describes the setting an event happens in, such as a position, a test, or a specific component. Unlike globals, context only applies to events triggered inside the element that sets it.

```
<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>
```

Each context property is a tuple of the value and an index, where the closest parent has index 0 (`[value, index]`). Access the value via `event.context.key[0]`.

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

tip

Context is useful for predefined journeys and stages, to measure events along a specific user journey in a structured way.

## Nested entities[​](#nested-entities "Direct link to Nested entities")

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

The walker collects nested entities like regular entities, with all their related information, and lists them in the `nested` array of each event.

```
<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 leads to the following event on load:

```
{
"name": "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
}
```

An entity nested inside another nested entity appears on both levels.

note

Auto-captured page view events have no nested entities.

tip

The [event model](/preview/pr-720/docs/getting-started/event-model.md) describes the full walkerOS event structure.

## 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 things 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 instead of 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. Without further setup you track that content by calling `walker init` on its container after each injection. If you mark the container with `data-elbobserve`, the browser source watches it instead: tagged content injected into it is registered for tracking automatically, and its triggers are cleaned up when the content is removed.

Tagged content already present in the container is registered by the normal page scan. `data-elbobserve` only affects content injected later.

```
<!-- 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` takes no value. Its presence marks the container.

### Open shadow roots[​](#open-shadow-roots-1 "Direct link to Open shadow roots")

A `data-elbobserve` container inside an open shadow root gets 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 the source 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 make the framework create 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 both observers process the added nodes.
* **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.
