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

[Source code](https://github.com/elbwalker/walkerOS/tree/main/packages/web/sources/browser)[ Package](https://www.npmjs.com/package/@walkeros/web-source-browser)

# Browser source

Captures user interactions directly from the DOM using `data-elb-*` attributes. Extracts tracking data from markup, tracks pageviews automatically or manually, exposes an enhanced `elb` function with `walker init` support, and provides a trigger system covering click, submit, load, hover, scroll, and visible.

Where this fits

The browser source is a **source** in the walkerOS flow:

It reads \`data-elb-\*\` attributes from your HTML and generates events. The Collector routes these to your configured destinations.

## Installation[​](#installation "Direct link to Installation")

* Integrated
* Bundled

Install the packages:

```bash
npm install @walkeros/collector @walkeros/web-source-browser @walkeros/web-source-session
```

Configure in your code:

```typescript
import { startFlow } from '@walkeros/collector';
import { sourceBrowser } from '@walkeros/web-source-browser';
import { sourceSession } from '@walkeros/web-source-session';

const { collector, elb } = await startFlow({
  sources: {
    browser: { code: sourceBrowser },
    session: { code: sourceSession }, // Add for session tracking
  },
});
```

Add to your `flow.json` sources:

```json
"sources": {
  "browser": {
    "package": "@walkeros/web-source-browser"
  },
  "session": {
    "package": "@walkeros/web-source-session",
    "config": {
      "settings": {
        "storage": true
      }
    }
  }
}
```

[See bundled mode setup](/preview/pr-749/docs/getting-started/modes/bundled.md) | [CLI reference](/preview/pr-749/docs/apps/cli.md)

## Configuration[​](#configuration "Direct link to Configuration")

This source uses the standard source config wrapper (consent, data, env, id, ...). For the shared fields see [source configuration](/docs/sources#configuration). Package-specific fields live under `config.settings` and are listed below.

## Settings[​](#settings "Direct link to Settings")

| Property   | Type                       | Description                                                                                                                                                               | More |
| ---------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---- |
| `prefix`   | `string`                   | Prefix for data attributes (default: data-elb)                                                                                                                            |      |
| `scope`    | `string`                   | DOM scope for event tracking (default: document)                                                                                                                          |      |
| `pageview` | `boolean`                  | Enable automatic pageview tracking                                                                                                                                        |      |
| `capture`  | `boolean`                  | Read click/submit triggers in the capture phase so tagged elements are resolved at click time before app handlers run (set false for the previous bubble-phase behavior)  |      |
| `history`  | `boolean`                  | Single-page apps: send a page view on every route change (pushState, replaceState with a new path, back and forward) and pick up tagged elements as the new route renders |      |
| `elb`      | `string \| boolean`        | Name for global elb function, or false to install none                                                                                                                    |      |
| `name`     | `string`                   | Custom name for source instance                                                                                                                                           |      |
| `elbLayer` | `boolean \| string \| any` | Enable elbLayer for async command queuing (boolean, string, or Elb.Layer)                                                                                                 |      |

## Mapping[​](#mapping "Direct link to Mapping")

This package does not define custom rule-level settings. For the standard rule fields (consent, condition, data, batch, name, policy) see [mapping](/docs/mapping).

## Examples

### Click event

A button click with walker data attributes is captured as an entity action event with the mapped label data.

Event

```json
"<button data-elb=\"cta\" data-elb-cta=\"label:Sign Up\" data-elbaction=\"click:click\">Sign Up</button>"
```

Out

```javascript
elb({
  "name": "cta click",
  "entity": "cta",
  "action": "click",
  "data": {
    "label": "Sign Up"
  },
  "context": {},
  "globals": {},
  "nested": [],
  "source": {
    "type": "browser",
    "platform": "web",
    "url": "https://example.com/",
    "referrer": ""
  },
  "trigger": "click"
})
```

### Context and globals

Walker context and globals attributes attach ambient metadata to events emitted by the browser source.

Event

```json
"<div data-elbcontext=\"test:engagement_flow\" data-elbglobals=\"language:en;plan:premium\"><div data-elb=\"cta\" data-elb-cta=\"label:Try Now\" data-elbaction=\"click:signup\">Try Now</div></div>"
```

Out

```javascript
elb({
  "name": "cta signup",
  "entity": "cta",
  "action": "signup",
  "data": {
    "label": "Try Now"
  },
  "context": {
    "test": [
      "engagement_flow",
      0
    ]
  },
  "globals": {
    "language": "en",
    "plan": "premium"
  },
  "nested": [],
  "source": {
    "type": "browser",
    "platform": "web",
    "url": "https://example.com/",
    "referrer": ""
  },
  "trigger": "click"
})
```

### Data attribute types

Walker data attributes parse scalar, boolean, and array values into typed fields on the emitted event.

Event

```json
"<div data-elb=\"product\" data-elb-product=\"price:99.99;available:true;colors[]:red;colors[]:blue\" data-elbaction=\"click:select\"></div>"
```

Out

```javascript
elb({
  "name": "product select",
  "entity": "product",
  "action": "select",
  "data": {
    "price": 99.99,
    "available": true,
    "colors": [
      "red",
      "blue"
    ]
  },
  "context": {},
  "globals": {},
  "nested": [],
  "source": {
    "type": "browser",
    "platform": "web",
    "url": "https://example.com/",
    "referrer": ""
  },
  "trigger": "click"
})
```

### Impression event

An intersection observer impression on a div with walker attributes emits a banner view event.

Event

```json
"<div data-elb=\"banner\" data-elb-banner=\"type:promotional;position:sidebar\" data-elbaction=\"impression:view\"></div>"
```

Out

```javascript
elb({
  "name": "banner view",
  "entity": "banner",
  "action": "view",
  "data": {
    "type": "promotional",
    "position": "sidebar"
  },
  "context": {},
  "globals": {},
  "nested": [],
  "source": {
    "type": "browser",
    "platform": "web",
    "url": "https://example.com/",
    "referrer": ""
  },
  "trigger": "impression"
})
```

### Nested entities

A page load produces a page view plus a product view whose nested size entity is captured on the product event.

Event

```json
"<div data-elb=\"product\" data-elb-product=\"id:SKU-42;name:Sneakers\" data-elbaction=\"load:view\"><div data-elb=\"size\" data-elb-size=\"selected:large;inStock:true\"></div></div>"
```

Out

```javascript
elb({
  "name": "page view",
  "data": {
    "domain": "example.com",
    "title": "",
    "referrer": "",
    "id": "/"
  },
  "context": {},
  "globals": {},
  "trigger": "load",
  "source": {
    "type": "browser",
    "platform": "web",
    "url": "https://example.com/",
    "referrer": ""
  }
});

elb({
  "name": "product view",
  "entity": "product",
  "action": "view",
  "data": {
    "id": "SKU-42",
    "name": "Sneakers"
  },
  "context": {},
  "globals": {},
  "nested": [
    {
      "entity": "size",
      "data": {
        "selected": "large",
        "inStock": true
      },
      "context": {},
      "nested": []
    }
  ],
  "source": {
    "type": "browser",
    "platform": "web",
    "url": "https://example.com/",
    "referrer": ""
  },
  "trigger": "load"
})
```

### Page view

A page load trigger captures the current URL, title, and referrer as a walker page view event.

Event

```json
""
```

Out

```javascript
elb({
  "name": "page view",
  "data": {
    "domain": "example.com",
    "title": "Documentation",
    "referrer": "https://example.com/",
    "id": "/docs"
  },
  "context": {},
  "globals": {},
  "trigger": "load",
  "source": {
    "type": "browser",
    "platform": "web",
    "url": "https://example.com/docs",
    "referrer": "https://example.com/"
  }
})
```

### Route change

With history enabled, a single-page app route change sends a page view for the new URL, with the previous page as its referrer.

Event

```json
""
```

Out

```javascript
elb({
  "name": "page view",
  "data": {
    "domain": "example.com",
    "title": "",
    "referrer": "",
    "id": "/docs"
  },
  "context": {},
  "globals": {},
  "trigger": "load",
  "source": {
    "type": "browser",
    "platform": "web",
    "url": "https://example.com/docs",
    "referrer": ""
  }
});

elb({
  "name": "page view",
  "data": {
    "domain": "example.com",
    "title": "",
    "referrer": "https://example.com/docs",
    "id": "/pricing"
  },
  "context": {},
  "globals": {},
  "trigger": "load",
  "source": {
    "type": "browser",
    "platform": "web",
    "url": "https://example.com/pricing",
    "referrer": ""
  }
})
```

### Submit event

A form submit with walker data attributes produces a signup complete event carrying the selected plan.

Event

```json
"<form data-elb=\"signup\" data-elb-signup=\"plan:premium\" data-elbaction=\"submit:complete\"></form>"
```

Out

```javascript
elb({
  "name": "signup complete",
  "entity": "signup",
  "action": "complete",
  "data": {
    "plan": "premium"
  },
  "context": {},
  "globals": {},
  "nested": [],
  "source": {
    "type": "browser",
    "platform": "web",
    "url": "https://example.com/",
    "referrer": ""
  },
  "trigger": "submit"
})
```

## Enhanced elb[​](#enhanced-elb "Direct link to Enhanced elb")

With a browser source in `sources`, the `elb` that `startFlow` returns is the browser source's own, and the source installs it as `window.elb` as well. It takes the same [partial event](/preview/pr-749/docs/getting-started/event-model.md#push-an-event) as the collector's `elb`:

```typescript
// The event also gets source.url, source.referrer and the page's globals
await elb({ name: 'product view', data: { id: 'P123' } });

// A DOM command: register tagged elements added after the page loaded
await elb('walker init', document.querySelector('#app'));
```

**Browser source elb adds:**

* **Page context**: the tagged globals on every event, plus `source.url` and `source.referrer` unless the event brings its own `source`
* **DOM commands**: `walker init` for asynchronous DOM element loading
* **elbLayer integration**: automatic processing of queued commands

See [Commands](/preview/pr-749/docs/sources/web/browser/commands.md) for complete API documentation.

## Single-page apps[​](#single-page-apps "Direct link to Single-page apps")

With `history: true` the source follows route changes by itself. [walker.js](/preview/pr-749/docs/getting-started/quickstart/walkerjs.md) has it on; in your own bundle, set it on the browser source:

```json
"browser": {
  "package": "@walkeros/web-source-browser",
  "config": {
    "settings": { "history": true }
  }
}
```

**What counts as a route change.** A `pushState` or a back or forward navigation that changes the path or the query, and a `replaceState` that changes the path. A `replaceState` that only rewrites the query (filters, search boxes) and hash-only changes do not count. Each comparison is against the URL right before that change.

**What a route change sends.** A new run starts at once: a `page view` for the URL at the moment of the change, with the previous page view's URL (without its fragment) as `data.referrer`. `source.referrer` stays the document's referrer, for attribution. Route changes never start a new session. Every page view after the first carries the previous URL this way, also when you call `elb('walker run')` yourself, so a URL redaction rule for personal data in query strings must cover `data.referrer` too.

**Tagged elements.** A route change does not re-scan the page. The source watches the document and registers tagged elements whenever they are added, one task after they appear, so a route's `load` events follow its `page view`. On the first page load the page's `load` elements fire before the `page view`.

**Migrating.** Remove every route-change `elb('walker run')` call and every GTM History Change tag that calls it. With `history` on, they make every navigation send two page views and fire the page's `load` elements again.

**Reused route components.** The source watches added and removed elements, not attribute changes. A router that reuses a component when only a parameter changes patches its attributes in place, so its tagged elements do not fire again. Remount the component per route: React `key={id}`, Vue `<RouterView :key="route.fullPath">`, Svelte `{#key}`, Next.js `template.tsx`. Values read with `#innerText` on a reused element do not fire again either.

**Elements that stay across routes** fire `load` and `impression` once per page load, and their `scroll`, `wait` and `pulse` triggers are not re-armed. A promo in a persistent layout counts one impression per page load. For per-route counts, tag elements inside the route's content, or use `visible`.

**Page title, page attributes and globals** are read when the run reaches the `page view`. Routers that render or set the title later (React Router, Next.js Pages Router, TanStack Router, Vue with unhead, Nuxt) report the previous route's values. When you need exact values, keep `history: false` and call `elb('walker run')` from the router's post-render hook:

| Router             | Hook                                                       |
| ------------------ | ---------------------------------------------------------- |
| React Router       | `useLayoutEffect` on `location.key`                        |
| TanStack Router    | `onRendered`                                               |
| Vue Router         | `afterEach` plus `nextTick` (title: unhead `dom:rendered`) |
| Nuxt               | `page:finish` (title: unhead `dom:rendered`)               |
| Angular            | `NavigationEnd` plus `afterNextRender`                     |
| SvelteKit          | `afterNavigate`                                            |
| Astro              | `astro:page-load`                                          |
| Next.js App Router | no reliable hook                                           |

**Consent.** A destination that waits for consent holds the events from before the visitor's answer, and every run clears that buffer. A route change is a run, so when the visitor navigates before answering the banner, the landing page view is lost, and so is its `session start` unless the session source itself waits for consent. A multi-page site loses the same when the visitor follows a link.

**One per page.** Run one browser source with `history` on a page.

**Not covered:** hash routers, same-origin iframes, HTML-over-the-wire routers (Turbo, htmx, Livewire), tagged content rendered later inside shadow roots, and `data-elbobserve` containers inside shadow roots while `history` is on. Next.js 16 `cacheComponents` reveals cached routes on revisit without adding elements, so those revisits send a `page view` but no `load` events; use `visible` instead.

With `history: false` (the default) route changes are not followed, `data-elbobserve` containers work as before, and `elb('walker run')` re-scans the page.

## Multiple sources on one page[​](#multiple-sources-on-one-page "Direct link to Multiple sources on one page")

Any number of browser sources can run on one page. Each keeps its own triggers, its own element registry, and its own visibility state, so nothing one source wires, times, or tears down reaches another.

Two page-level resources are the exception, because each is a single slot:

| Resource                                | Setting    | Rule                                           |
| --------------------------------------- | ---------- | ---------------------------------------------- |
| The writer installed at `window[elb]`   | `elb`      | The first source to install the name keeps it. |
| The command queue at `window[elbLayer]` | `elbLayer` | The first source to adopt the array keeps it.  |

A source that asks for a name another source already holds is refused that one resource and logs an error. Everything else keeps working: its scope is still scanned, its triggers still fire, and its own `push` still resolves. A page's own bootstrap, the `elb` stub function and pre-filled `elbLayer` array defined before the source loads, belongs to no source and is adopted as usual.

The DOM is not owned. Any number of sources may scan the same node, `document` included. That is how two flows run on one page: give each its own `elb` name and its own `elbLayer` name (or `false`), and let them share the markup.

### A vendor flow beside the page's own[​](#a-vendor-flow-beside-the-pages-own "Direct link to A vendor flow beside the page's own")

Distinct names on both single-slot resources mean nothing is refused on either side. `pageview: false` leaves the `page view` event to the walker the page already runs, and the scope narrows the vendor's delegation to the subtree it cares about.

```typescript
import { startFlow } from '@walkeros/collector';
import { sourceBrowser } from '@walkeros/web-source-browser';

const { collector, elb } = await startFlow({
  sources: {
    browser: {
      code: sourceBrowser,
      config: {
        settings: {
          elb: 'vendorElb',
          elbLayer: 'vendorLayer',
          scope: document.body,
          pageview: false,
        },
      },
    },
  },
});
```

### An embedded overlay with no window footprint[​](#an-embedded-overlay-with-no-window-footprint "Direct link to An embedded overlay with no window footprint")

`elb: false` installs no window property at all and `elbLayer: false` creates no queue, so the source adds nothing to the page window and reaches its collector through the `elb` that `startFlow` returns. Pair it with an explicit scope and the overlay captures only its own markup.

```typescript
import { startFlow } from '@walkeros/collector';
import { sourceBrowser } from '@walkeros/web-source-browser';

const host = document.createElement('div');
document.body.appendChild(host);
const overlay = host.attachShadow({ mode: 'closed' });

const { collector, elb } = await startFlow({
  sources: {
    browser: {
      code: sourceBrowser,
      config: {
        settings: {
          elb: false,
          elbLayer: false,
          scope: overlay,
          pageview: false,
        },
      },
    },
  },
});
```

### One elbLayer belongs to one walker[​](#one-elblayer-belongs-to-one-walker "Direct link to One elbLayer belongs to one walker")

A queue is drained once. The source that adopts a named layer reads every entry, and a command like `walker run` has exactly one addressee, so a second source on the same array would starve the first rather than join it. Parallel flows use separate layer names or `elbLayer: false`, and share instrumentation through the `data-elb` attributes rather than through a queue.

### Overlapping scopes fire once per source[​](#overlapping-scopes-fire-once-per-source "Direct link to Overlapping scopes fire once per source")

Two sources scanning the same tagged element wire it twice. `click`, `hover`, `pulse`, `scroll`, `wait`, `impression`, and `visible` each fire once per source, into that source's own pipeline, and an observed element carries one dwell timer per source.

The `prefix` setting decides whether that is what you want:

* **Same prefix** is deliberate dual collect. One set of attributes feeds both pipelines, and each collector sees the interaction once.
* **Different prefix** isolates the two. Each source only sees elements tagged for it, so overlapping scopes cost nothing.

note

Give overlapping sources the same scope root. A source discovers [`data-elbobserve`](/preview/pr-749/docs/sources/web/browser/tagging/html-attributes.md#auto-init-with-data-elbobserve) containers inside its own scope only. When two sources share a prefix and the container sits outside one of their scopes, only the source that discovered it reaps elements the container removes. For the other one that element's teardown never runs: its `pulse` intervals keep firing, its `wait` timeouts stay armed, and its hover and visibility registrations stay in place against a node that has left the document, which also keeps that node referenced until the source is torn down. A shared scope root means both sources see the same containers.

### Known limits[​](#known-limits "Direct link to Known limits")

* **Version skew.** A page running an older release of the package leaves no ownership mark, so a source injected later reads that layer as free and adopts it. This only matters when both use the same layer name, which is what the contract above tells parallel flows not to do.
* **The same script included twice.** Each copy scans the same markup into its own pipeline, so every event is collected twice. That is a page misconfiguration rather than something the source can resolve, and it is diagnosable: both refusals log at initialization.

## Next steps[​](#next-steps "Direct link to Next steps")

* [Session source](/preview/pr-749/docs/sources/web/session.md) - session and device tracking
* [Tagging guide](/preview/pr-749/docs/sources/web/browser/tagging/html-attributes.md) - learn about data attributes
* [Commands reference](/preview/pr-749/docs/sources/web/browser/commands.md) - full API documentation
* [Tagger tool](/preview/pr-749/docs/sources/web/browser/tagger.md) - visual tagging tool
