diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8cf11569..a1e2a18e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
### Added
+- **Track:** add component for declarative analytics tracking with `data-track:*` attributes ([#495](https://github.com/studiometa/ui/issues/495), [#497](https://github.com/studiometa/ui/pull/497), [d95126d](https://github.com/studiometa/ui/commit/d95126d), [51b5f69](https://github.com/studiometa/ui/commit/51b5f69))
- **ScrollAnimation:** add a `withScrollAnimationDebug` decorator ([#494](https://github.com/studiometa/ui/pull/494))
### Fixed
diff --git a/packages/docs/components/Track/examples.md b/packages/docs/components/Track/examples.md
new file mode 100644
index 00000000..3c47a75d
--- /dev/null
+++ b/packages/docs/components/Track/examples.md
@@ -0,0 +1,261 @@
+---
+title: Track Examples
+---
+
+# Examples
+
+## E-commerce Tracking
+
+### Product List View
+
+```twig
+{# Track product list view on page load #}
+
+
+```
+
+### Product Click
+
+```twig
+
+ {{ product.name }}
+
+```
+
+### Add to Cart
+
+```twig
+
+```
+
+### Purchase Confirmation
+
+```twig
+{# On thank you page #}
+
+
+```
+
+## Navigation Tracking
+
+### Menu Click
+
+```twig
+
+
+ {% for item in menu_items %}
+
+ {{ item.title }}
+
+ {% endfor %}
+
+```
+
+### Footer Link
+
+```twig
+
+```
+
+## Form Tracking
+
+### Form Submission
+
+```twig
+
+```
+
+### Search Input (Debounced)
+
+```twig
+
+```
+
+## Impression Tracking
+
+### Product Card Impression
+
+```twig
+{% for product in products %}
+
+ {# Product card content #}
+
+{% endfor %}
+```
+
+### Banner Impression
+
+```twig
+
+
+
+```
+
+## Video Tracking
+
+### Video Play
+
+```twig
+
+
+
+```
+
+## Scroll Tracking
+
+### Scroll Depth (Throttled)
+
+```twig
+
+ {# Page content #}
+
+```
+
+## Third-Party Integration
+
+### Custom Event from External Script
+
+```html
+
+
+```
+
+## Multiple Events on Same Element
+
+```twig
+
+ {{ product.name }}
+
+```
diff --git a/packages/docs/components/Track/index.md b/packages/docs/components/Track/index.md
new file mode 100644
index 00000000..3fa60665
--- /dev/null
+++ b/packages/docs/components/Track/index.md
@@ -0,0 +1,159 @@
+---
+badges: [JS]
+---
+
+# Track
+
+The `Track` component provides declarative analytics tracking compatible with GTM/dataLayer, GA4, Segment, and custom backends. No custom JavaScript required - tracking is fully defined in HTML/Twig attributes.
+
+## Table of content
+
+- [Examples](./examples.md)
+- [JS API](./js-api.md)
+
+## Usage
+
+Import the component and register it in your application:
+
+```js
+import { Base, createApp } from '@studiometa/js-toolkit';
+import { Track, TrackContext } from '@studiometa/ui';
+
+class App extends Base {
+ static config = {
+ name: 'App',
+ components: {
+ Track,
+ TrackContext,
+ },
+ };
+}
+
+createApp(App);
+```
+
+### Click Tracking
+
+Track user interactions with `data-track:click`:
+
+
+
+
+
+
+:::code-group
+
+<<< ./stories/basic/click.twig
+<<< ./stories/basic/app.js
+
+:::
+
+
+
+### Page Load Tracking
+
+Use `data-track:mounted` to dispatch tracking data when the component mounts (page load):
+
+
+
+
+
+
+:::code-group
+
+<<< ./stories/basic/mounted.twig
+<<< ./stories/basic/app.js
+
+:::
+
+
+
+### Impression Tracking
+
+Use `data-track:view` for IntersectionObserver-based impression tracking:
+
+
+
+
+
+
+:::code-group
+
+<<< ./stories/basic/view.twig
+<<< ./stories/basic/app.js
+
+:::
+
+
+
+### Hierarchical Context
+
+Use `TrackContext` to provide shared data that is merged into all child `Track` components:
+
+
+
+
+
+
+:::code-group
+
+<<< ./stories/basic/context.twig
+<<< ./stories/basic/app.js
+
+:::
+
+
+
+### Multiple Events
+
+Multiple events can be tracked on the same element:
+
+
+
+
+
+
+:::code-group
+
+<<< ./stories/basic/multiple.twig
+<<< ./stories/basic/app.js
+
+:::
+
+
+
+### Custom Events
+
+Listen to CustomEvents from third-party scripts and extract data from `event.detail`:
+
+
+
+
+
+
+:::code-group
+
+<<< ./stories/basic/custom-event.twig
+<<< ./stories/basic/app.js
+
+:::
+
+
diff --git a/packages/docs/components/Track/js-api.md b/packages/docs/components/Track/js-api.md
new file mode 100644
index 00000000..2cb82a05
--- /dev/null
+++ b/packages/docs/components/Track/js-api.md
@@ -0,0 +1,204 @@
+---
+title: Track JS API
+---
+
+# JS API
+
+## Options
+
+### `threshold`
+
+- Type: `number`
+- Default: `0.5`
+
+The IntersectionObserver threshold used for the `view` event. A value of `0.5` means the tracking will trigger when 50% of the element is visible.
+
+```html
+
+ Product Card
+
+```
+
+## Events
+
+Events are defined using the `data-track:[.]` syntax with a JSON payload.
+
+### DOM Events
+
+Any DOM event can be tracked: `click`, `submit`, `change`, `input`, `focus`, `blur`, `scroll`, `mouseenter`, `mouseleave`, etc.
+
+```html
+
+ Subscribe
+
+```
+
+### Special Events
+
+#### `mounted`
+
+Dispatches tracking data immediately when the component mounts. Useful for page load data like ecommerce views.
+
+```html
+
+```
+
+#### `view`
+
+Uses IntersectionObserver for impression tracking. The event fires when the element becomes visible based on the `threshold` option.
+
+```html
+
+ Product Card
+
+```
+
+## Event Modifiers
+
+Modifiers can be chained using `.` as a separator:
+
+```html
+
+ View Product
+
+```
+
+### Available Modifiers
+
+| Modifier | Effect |
+| -------------- | ------------------------------------------------------------ |
+| `.prevent` | Calls `event.preventDefault()` |
+| `.stop` | Calls `event.stopPropagation()` |
+| `.once` | Track only once (removes listener after first trigger) |
+| `.passive` | Registers a passive event listener |
+| `.capture` | Registers the listener in capture phase |
+| `.debounce` | Debounces the handler with a 300ms delay |
+| `.debounce` | Debounces with custom delay (e.g., `.debounce500` for 500ms) |
+| `.throttle` | Throttles the handler with a 16ms delay (~60fps) |
+| `.throttle` | Throttles with custom delay (e.g., `.throttle100` for 100ms) |
+
+### Timing Modifiers Examples
+
+```html
+
+
+
+
+
+
+
+
+
+
+
+```
+
+## Custom Events
+
+The Track component can listen to CustomEvents dispatched by other scripts.
+
+### Using `$detail.*` Placeholders
+
+Extract specific values from `event.detail` using the `$detail.*` syntax:
+
+```html
+
+```
+
+If the form dispatches:
+
+```js
+element.dispatchEvent(
+ new CustomEvent('form-submitted', {
+ detail: { email: 'test@example.com', user: { name: 'John' } },
+ }),
+);
+```
+
+The tracking data will be:
+
+```json
+{ "event": "form_submitted", "email": "test@example.com", "name": "John" }
+```
+
+## TrackContext
+
+The `TrackContext` component provides hierarchical context data that is merged into all child `Track` components.
+
+### Options
+
+#### `data`
+
+- Type: `object`
+- Default: `{}`
+
+The context data to merge into child Track components.
+
+```html
+
+```
+
+### Nested Contexts
+
+When Track is nested in multiple TrackContext components, it uses the data from the **closest parent** only:
+
+```html
+
+
+
+```
+
+## Custom Dispatcher
+
+By default, Track pushes data to `window.dataLayer` (GTM). You can customize the dispatcher:
+
+```js
+import { setTrackDispatcher } from '@studiometa/ui';
+
+// Send to GA4 directly
+setTrackDispatcher((data, event) => {
+ gtag('event', data.event, data);
+});
+
+// Send to multiple destinations
+setTrackDispatcher((data) => {
+ window.dataLayer.push(data);
+ fetch('/api/analytics', { method: 'POST', body: JSON.stringify(data) });
+});
+
+// Reset to default (dataLayer.push)
+setTrackDispatcher(null);
+```
+
+## Methods
+
+### `dispatch(data, event?)`
+
+Manually dispatch tracking data. The data is merged with any parent TrackContext data.
+
+```js
+import { getInstanceFromElement } from '@studiometa/js-toolkit';
+import { Track } from '@studiometa/ui';
+
+const element = document.querySelector('[data-component="Track"]');
+const track = getInstanceFromElement(element, Track);
+track.dispatch({ event: 'custom_event', value: 123 });
+```
diff --git a/packages/docs/components/Track/stories/basic/app.js b/packages/docs/components/Track/stories/basic/app.js
new file mode 100644
index 00000000..233bbcea
--- /dev/null
+++ b/packages/docs/components/Track/stories/basic/app.js
@@ -0,0 +1,27 @@
+import { Base, createApp } from '@studiometa/js-toolkit';
+import { Track, TrackContext, setTrackDispatcher } from '@studiometa/ui';
+
+// Custom dispatcher that also updates debug elements
+setTrackDispatcher((data) => {
+ // Push to dataLayer (default behavior)
+ window.dataLayer = window.dataLayer || [];
+ window.dataLayer.push(data);
+
+ // Update debug elements
+ const debugEl = document.querySelector('[data-debug-datalayer]');
+ if (debugEl) {
+ debugEl.textContent = JSON.stringify(window.dataLayer, null, 2);
+ }
+});
+
+class App extends Base {
+ static config = {
+ name: 'App',
+ components: {
+ Track,
+ TrackContext,
+ },
+ };
+}
+
+export default createApp(App);
diff --git a/packages/docs/components/Track/stories/basic/click.twig b/packages/docs/components/Track/stories/basic/click.twig
new file mode 100644
index 00000000..563bb2f2
--- /dev/null
+++ b/packages/docs/components/Track/stories/basic/click.twig
@@ -0,0 +1,13 @@
+
+ Subscribe
+
+
+
diff --git a/packages/docs/components/Track/stories/basic/context.twig b/packages/docs/components/Track/stories/basic/context.twig
new file mode 100644
index 00000000..994d0b35
--- /dev/null
+++ b/packages/docs/components/Track/stories/basic/context.twig
@@ -0,0 +1,33 @@
+
+
+
+
+ All buttons inherit context data from the parent TrackContext.
+
+
+
+
+ Add to Cart
+
+
+
+ Add to Wishlist
+
+
+
+
+
+
diff --git a/packages/docs/components/Track/stories/basic/custom-event.twig b/packages/docs/components/Track/stories/basic/custom-event.twig
new file mode 100644
index 00000000..0f6ca961
--- /dev/null
+++ b/packages/docs/components/Track/stories/basic/custom-event.twig
@@ -0,0 +1,28 @@
+
+
+ This example shows how to track CustomEvent from third-party scripts
+ using the $detail.* syntax.
+
+
+
+
+
+
diff --git a/packages/docs/components/Track/stories/basic/mounted.twig b/packages/docs/components/Track/stories/basic/mounted.twig
new file mode 100644
index 00000000..a3f6a106
--- /dev/null
+++ b/packages/docs/components/Track/stories/basic/mounted.twig
@@ -0,0 +1,17 @@
+
+
+
+
+
+ The page_view event was dispatched when this component mounted.
+
+
+
diff --git a/packages/docs/components/Track/stories/basic/multiple.twig b/packages/docs/components/Track/stories/basic/multiple.twig
new file mode 100644
index 00000000..dd970c5d
--- /dev/null
+++ b/packages/docs/components/Track/stories/basic/multiple.twig
@@ -0,0 +1,21 @@
+
diff --git a/packages/docs/components/Track/stories/basic/view.twig b/packages/docs/components/Track/stories/basic/view.twig
new file mode 100644
index 00000000..29e2cec0
--- /dev/null
+++ b/packages/docs/components/Track/stories/basic/view.twig
@@ -0,0 +1,41 @@
+
+
+ Scroll down to see the product cards. Each card tracks an impression when it becomes visible.
+
+
+
+
+ ↓ Scroll down ↓
+
+
+
+ Product A - Impression tracked once when visible
+
+
+
+ Product B - Impression tracked once when visible
+
+
+
+ Product C - Impression tracked once when visible
+
+
+
+
+
+
+
diff --git a/packages/tests/Track/Track.spec.ts b/packages/tests/Track/Track.spec.ts
new file mode 100644
index 00000000..082b31a0
--- /dev/null
+++ b/packages/tests/Track/Track.spec.ts
@@ -0,0 +1,345 @@
+import { describe, it, vi, expect, beforeEach, afterEach, beforeAll } from 'vitest';
+import { Track, TrackContext, setTrackDispatcher } from '@studiometa/ui';
+import {
+ h,
+ mount,
+ destroy,
+ intersectionObserverBeforeAllCallback,
+ intersectionObserverAfterEachCallback,
+ mockIsIntersecting,
+} from '#test-utils';
+
+describe('The Track component', () => {
+ let dispatcherSpy: ReturnType;
+
+ beforeAll(() => {
+ intersectionObserverBeforeAllCallback();
+ });
+
+ beforeEach(() => {
+ // Reset dataLayer
+ window.dataLayer = [];
+ // Create spy for custom dispatcher
+ dispatcherSpy = vi.fn();
+ });
+
+ afterEach(() => {
+ intersectionObserverAfterEachCallback();
+ setTrackDispatcher(null);
+ });
+
+ it('should dispatch on click event', async () => {
+ const div = h('div', {
+ 'data-track:click': JSON.stringify({ event: 'cta_click', location: 'header' }),
+ });
+ const track = new Track(div);
+ await mount(track);
+
+ track.$el.dispatchEvent(new Event('click'));
+
+ expect(window.dataLayer).toHaveLength(1);
+ expect(window.dataLayer![0]).toEqual({ event: 'cta_click', location: 'header' });
+
+ await destroy(track);
+ });
+
+ it('should dispatch on mounted event', async () => {
+ const div = h('div', {
+ 'data-track:mounted': JSON.stringify({ event: 'page_view', page: 'home' }),
+ });
+ const track = new Track(div);
+ await mount(track);
+
+ expect(window.dataLayer).toHaveLength(1);
+ expect(window.dataLayer![0]).toEqual({ event: 'page_view', page: 'home' });
+
+ await destroy(track);
+ });
+
+ it('should dispatch on view event with IntersectionObserver', async () => {
+ const div = h('div', {
+ 'data-track:view': JSON.stringify({ event: 'product_impression', id: '123' }),
+ });
+ const track = new Track(div);
+ await mount(track);
+
+ // Initially not intersecting
+ expect(window.dataLayer).toHaveLength(0);
+
+ // Trigger intersection
+ await mockIsIntersecting(track.$el, true);
+
+ expect(window.dataLayer).toHaveLength(1);
+ expect(window.dataLayer![0]).toEqual({ event: 'product_impression', id: '123' });
+
+ await destroy(track);
+ });
+
+ it('should dispatch only once with .once modifier on view event', async () => {
+ const div = h('div', {
+ 'data-track:view.once': JSON.stringify({ event: 'product_impression', id: '123' }),
+ });
+ const track = new Track(div);
+ await mount(track);
+
+ // Trigger intersection - should dispatch once then disconnect
+ await mockIsIntersecting(track.$el, true);
+
+ expect(window.dataLayer).toHaveLength(1);
+ expect(window.dataLayer![0]).toEqual({ event: 'product_impression', id: '123' });
+
+ await destroy(track);
+ });
+
+ it('should support multiple events on same element', async () => {
+ const div = h('div', {
+ 'data-track:click': JSON.stringify({ event: 'click_event' }),
+ 'data-track:mouseenter': JSON.stringify({ event: 'hover_event' }),
+ });
+ const track = new Track(div);
+ await mount(track);
+
+ track.$el.dispatchEvent(new Event('click'));
+ track.$el.dispatchEvent(new Event('mouseenter'));
+
+ expect(window.dataLayer).toHaveLength(2);
+ expect(window.dataLayer![0]).toEqual({ event: 'click_event' });
+ expect(window.dataLayer![1]).toEqual({ event: 'hover_event' });
+
+ await destroy(track);
+ });
+
+ it('should use custom dispatcher when set', async () => {
+ setTrackDispatcher(dispatcherSpy);
+
+ const div = h('div', {
+ 'data-track:click': JSON.stringify({ event: 'test_event' }),
+ });
+ const track = new Track(div);
+ await mount(track);
+
+ track.$el.dispatchEvent(new Event('click'));
+
+ expect(dispatcherSpy).toHaveBeenCalledTimes(1);
+ expect(dispatcherSpy).toHaveBeenCalledWith(
+ { event: 'test_event' },
+ expect.any(Event),
+ );
+ // Default dataLayer should not be used
+ expect(window.dataLayer).toHaveLength(0);
+
+ await destroy(track);
+ });
+
+ it('should merge context data from TrackContext parent', async () => {
+ const contextDiv = h('div', {
+ 'data-option-data': JSON.stringify({ page_type: 'product', product_id: '123' }),
+ });
+ const trackDiv = h('div', {
+ 'data-track:click': JSON.stringify({ action: 'add_to_cart' }),
+ });
+ contextDiv.appendChild(trackDiv);
+
+ const context = new TrackContext(contextDiv);
+ const track = new Track(trackDiv);
+ await mount(context, track);
+
+ track.$el.dispatchEvent(new Event('click'));
+
+ expect(window.dataLayer).toHaveLength(1);
+ expect(window.dataLayer![0]).toEqual({
+ page_type: 'product',
+ product_id: '123',
+ action: 'add_to_cart',
+ });
+
+ await destroy(track, context);
+ });
+
+ it('should apply .prevent modifier', async () => {
+ const div = h('div', {
+ 'data-track:click.prevent': JSON.stringify({ event: 'test' }),
+ });
+ const track = new Track(div);
+ await mount(track);
+
+ const event = new Event('click', { cancelable: true });
+ const preventSpy = vi.spyOn(event, 'preventDefault');
+ track.$el.dispatchEvent(event);
+
+ expect(preventSpy).toHaveBeenCalledTimes(1);
+
+ await destroy(track);
+ });
+
+ it('should apply .stop modifier', async () => {
+ const div = h('div', {
+ 'data-track:click.stop': JSON.stringify({ event: 'test' }),
+ });
+ const track = new Track(div);
+ await mount(track);
+
+ const event = new Event('click', { bubbles: true });
+ const stopSpy = vi.spyOn(event, 'stopPropagation');
+ track.$el.dispatchEvent(event);
+
+ expect(stopSpy).toHaveBeenCalledTimes(1);
+
+ await destroy(track);
+ });
+
+ it('should warn on invalid JSON', async () => {
+ const div = h('div', {
+ 'data-track:click': 'invalid json',
+ });
+ const track = new Track(div);
+ // $warn is a getter, so we spy on it with 'get'
+ const warnFn = vi.fn();
+ vi.spyOn(track, '$warn', 'get').mockReturnValue(warnFn);
+ await mount(track);
+
+ expect(warnFn).toHaveBeenCalled();
+
+ await destroy(track);
+ });
+
+ it('should handle CustomEvent with $detail.* placeholders', async () => {
+ const div = h('div', {
+ 'data-track:form-submitted': JSON.stringify({
+ event: 'form_submitted',
+ email: '$detail.email',
+ name: '$detail.user.name',
+ }),
+ });
+ const track = new Track(div);
+ await mount(track);
+
+ const customEvent = new CustomEvent('form-submitted', {
+ detail: {
+ email: 'test@example.com',
+ user: { name: 'John' },
+ },
+ });
+ track.$el.dispatchEvent(customEvent);
+
+ expect(window.dataLayer).toHaveLength(1);
+ expect(window.dataLayer![0]).toEqual({
+ event: 'form_submitted',
+ email: 'test@example.com',
+ name: 'John',
+ });
+
+ await destroy(track);
+ });
+
+ it('should merge full event.detail with .detail modifier', async () => {
+ const div = h('div', {
+ 'data-track:custom-event.detail': JSON.stringify({
+ event: 'custom_tracking',
+ source: 'component',
+ }),
+ });
+ const track = new Track(div);
+ await mount(track);
+
+ const customEvent = new CustomEvent('custom-event', {
+ detail: {
+ extra: 'data',
+ nested: { value: 123 },
+ },
+ });
+ track.$el.dispatchEvent(customEvent);
+
+ expect(window.dataLayer).toHaveLength(1);
+ expect(window.dataLayer![0]).toEqual({
+ event: 'custom_tracking',
+ source: 'component',
+ extra: 'data',
+ nested: { value: 123 },
+ });
+
+ await destroy(track);
+ });
+
+ it('should debounce events with default delay', async () => {
+ const div = h('div', {
+ 'data-track:input.debounce': JSON.stringify({ event: 'search_input' }),
+ });
+ const track = new Track(div);
+ await mount(track);
+
+ // Enable fake timers after mounting
+ vi.useFakeTimers();
+
+ // Trigger multiple events quickly
+ track.$el.dispatchEvent(new Event('input'));
+ track.$el.dispatchEvent(new Event('input'));
+ track.$el.dispatchEvent(new Event('input'));
+
+ // Should not be called immediately
+ expect(window.dataLayer).toHaveLength(0);
+
+ // Fast forward 150ms (less than default 300ms)
+ vi.advanceTimersByTime(150);
+ expect(window.dataLayer).toHaveLength(0);
+
+ // Fast forward another 150ms (total 300ms)
+ vi.advanceTimersByTime(150);
+ expect(window.dataLayer).toHaveLength(1);
+
+ vi.useRealTimers();
+ await destroy(track);
+ });
+
+ it('should debounce events with custom delay', async () => {
+ const div = h('div', {
+ 'data-track:input.debounce500': JSON.stringify({ event: 'search_input' }),
+ });
+ const track = new Track(div);
+ await mount(track);
+
+ // Enable fake timers after mounting
+ vi.useFakeTimers();
+
+ track.$el.dispatchEvent(new Event('input'));
+
+ // Should not be called after 300ms
+ vi.advanceTimersByTime(300);
+ expect(window.dataLayer).toHaveLength(0);
+
+ // Should be called after 500ms total
+ vi.advanceTimersByTime(200);
+ expect(window.dataLayer).toHaveLength(1);
+
+ vi.useRealTimers();
+ await destroy(track);
+ });
+
+ it('should throttle events with default delay', async () => {
+ const div = h('div', {
+ 'data-track:scroll.throttle': JSON.stringify({ event: 'scroll_tracking' }),
+ });
+ const track = new Track(div);
+ await mount(track);
+
+ // Enable fake timers after mounting
+ vi.useFakeTimers();
+
+ // First event should fire immediately (throttle behavior)
+ track.$el.dispatchEvent(new Event('scroll'));
+ expect(window.dataLayer).toHaveLength(1);
+
+ // Subsequent events within throttle window should be ignored
+ track.$el.dispatchEvent(new Event('scroll'));
+ track.$el.dispatchEvent(new Event('scroll'));
+ expect(window.dataLayer).toHaveLength(1);
+
+ // After throttle delay, next event should fire
+ vi.advanceTimersByTime(16);
+ track.$el.dispatchEvent(new Event('scroll'));
+ expect(window.dataLayer).toHaveLength(2);
+
+ vi.useRealTimers();
+ await destroy(track);
+ });
+});
diff --git a/packages/tests/Track/TrackContext.spec.ts b/packages/tests/Track/TrackContext.spec.ts
new file mode 100644
index 00000000..775afdb2
--- /dev/null
+++ b/packages/tests/Track/TrackContext.spec.ts
@@ -0,0 +1,123 @@
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { Track, TrackContext, setTrackDispatcher } from '@studiometa/ui';
+import { h, mount, destroy } from '#test-utils';
+
+describe('The TrackContext component', () => {
+ beforeEach(() => {
+ window.dataLayer = [];
+ });
+
+ afterEach(() => {
+ setTrackDispatcher(null);
+ });
+
+ it('should provide context data to child Track components', async () => {
+ const contextDiv = h('div', {
+ 'data-option-data': JSON.stringify({ page: 'home' }),
+ });
+ const trackDiv = h('div', {
+ 'data-track:click': JSON.stringify({ action: 'click' }),
+ });
+ contextDiv.appendChild(trackDiv);
+
+ const context = new TrackContext(contextDiv);
+ const track = new Track(trackDiv);
+ await mount(context, track);
+
+ track.$el.dispatchEvent(new Event('click'));
+
+ expect(window.dataLayer![0]).toEqual({
+ page: 'home',
+ action: 'click',
+ });
+
+ await destroy(track, context);
+ });
+
+ it('should support nested TrackContext components', async () => {
+ const outerContext = h('div', {
+ 'data-option-data': JSON.stringify({ site: 'example.com' }),
+ });
+ const innerContext = h('div', {
+ 'data-option-data': JSON.stringify({ page: 'product', product_id: '123' }),
+ });
+ const trackDiv = h('div', {
+ 'data-track:click': JSON.stringify({ action: 'add_to_cart' }),
+ });
+
+ outerContext.appendChild(innerContext);
+ innerContext.appendChild(trackDiv);
+
+ const outer = new TrackContext(outerContext);
+ const inner = new TrackContext(innerContext);
+ const track = new Track(trackDiv);
+ await mount(outer, inner, track);
+
+ track.$el.dispatchEvent(new Event('click'));
+
+ // Track should use closest parent context (inner), not outer
+ expect(window.dataLayer![0]).toEqual({
+ page: 'product',
+ product_id: '123',
+ action: 'add_to_cart',
+ });
+
+ await destroy(track, inner, outer);
+ });
+
+ it('should work without TrackContext parent', async () => {
+ const trackDiv = h('div', {
+ 'data-track:click': JSON.stringify({ event: 'standalone' }),
+ });
+ const track = new Track(trackDiv);
+ await mount(track);
+
+ track.$el.dispatchEvent(new Event('click'));
+
+ expect(window.dataLayer![0]).toEqual({ event: 'standalone' });
+
+ await destroy(track);
+ });
+
+ it('should allow Track data to override context data', async () => {
+ const contextDiv = h('div', {
+ 'data-option-data': JSON.stringify({ page: 'home', version: '1' }),
+ });
+ const trackDiv = h('div', {
+ 'data-track:click': JSON.stringify({ page: 'override', action: 'click' }),
+ });
+ contextDiv.appendChild(trackDiv);
+
+ const context = new TrackContext(contextDiv);
+ const track = new Track(trackDiv);
+ await mount(context, track);
+
+ track.$el.dispatchEvent(new Event('click'));
+
+ expect(window.dataLayer![0]).toEqual({
+ page: 'override', // Overridden by Track
+ version: '1', // From context
+ action: 'click', // From Track
+ });
+
+ await destroy(track, context);
+ });
+
+ it('should default to empty object when no data option provided', async () => {
+ const contextDiv = h('div');
+ const trackDiv = h('div', {
+ 'data-track:click': JSON.stringify({ event: 'test' }),
+ });
+ contextDiv.appendChild(trackDiv);
+
+ const context = new TrackContext(contextDiv);
+ const track = new Track(trackDiv);
+ await mount(context, track);
+
+ track.$el.dispatchEvent(new Event('click'));
+
+ expect(window.dataLayer![0]).toEqual({ event: 'test' });
+
+ await destroy(track, context);
+ });
+});
diff --git a/packages/tests/Track/TrackEvent.spec.ts b/packages/tests/Track/TrackEvent.spec.ts
new file mode 100644
index 00000000..4ba5f0c6
--- /dev/null
+++ b/packages/tests/Track/TrackEvent.spec.ts
@@ -0,0 +1,180 @@
+import { describe, it, expect } from 'vitest';
+import { parseEventDefinition, resolveDetailPlaceholders } from '#private/Track/TrackEvent.js';
+
+describe('parseEventDefinition', () => {
+ it('should parse simple event', () => {
+ const result = parseEventDefinition('click');
+ expect(result).toEqual({
+ event: 'click',
+ modifiers: [],
+ debounceDelay: 0,
+ throttleDelay: 0,
+ });
+ });
+
+ it('should parse event with modifiers', () => {
+ const result = parseEventDefinition('click.prevent.stop');
+ expect(result).toEqual({
+ event: 'click',
+ modifiers: ['prevent', 'stop'],
+ debounceDelay: 0,
+ throttleDelay: 0,
+ });
+ });
+
+ it('should parse event with default debounce', () => {
+ const result = parseEventDefinition('input.debounce');
+ expect(result).toEqual({
+ event: 'input',
+ modifiers: ['debounce'],
+ debounceDelay: 300,
+ throttleDelay: 0,
+ });
+ });
+
+ it('should parse event with custom debounce delay', () => {
+ const result = parseEventDefinition('input.debounce500');
+ expect(result).toEqual({
+ event: 'input',
+ modifiers: ['debounce'],
+ debounceDelay: 500,
+ throttleDelay: 0,
+ });
+ });
+
+ it('should parse event with default throttle', () => {
+ const result = parseEventDefinition('scroll.throttle');
+ expect(result).toEqual({
+ event: 'scroll',
+ modifiers: ['throttle'],
+ debounceDelay: 0,
+ throttleDelay: 16,
+ });
+ });
+
+ it('should parse event with custom throttle delay', () => {
+ const result = parseEventDefinition('mousemove.throttle100');
+ expect(result).toEqual({
+ event: 'mousemove',
+ modifiers: ['throttle'],
+ debounceDelay: 0,
+ throttleDelay: 100,
+ });
+ });
+
+ it('should parse event with multiple modifiers including timing', () => {
+ const result = parseEventDefinition('click.prevent.stop.debounce200');
+ expect(result).toEqual({
+ event: 'click',
+ modifiers: ['prevent', 'stop', 'debounce'],
+ debounceDelay: 200,
+ throttleDelay: 0,
+ });
+ });
+
+ it('should parse all listener option modifiers', () => {
+ const result = parseEventDefinition('click.once.passive.capture');
+ expect(result).toEqual({
+ event: 'click',
+ modifiers: ['once', 'passive', 'capture'],
+ debounceDelay: 0,
+ throttleDelay: 0,
+ });
+ });
+});
+
+describe('resolveDetailPlaceholders', () => {
+ it('should resolve simple $detail.* placeholder', () => {
+ const data = { event: 'test', email: '$detail.email' };
+ const detail = { email: 'test@example.com' };
+
+ const result = resolveDetailPlaceholders(data, detail);
+
+ expect(result).toEqual({
+ event: 'test',
+ email: 'test@example.com',
+ });
+ });
+
+ it('should resolve nested $detail.* placeholder', () => {
+ const data = { event: 'test', name: '$detail.user.name' };
+ const detail = { user: { name: 'John' } };
+
+ const result = resolveDetailPlaceholders(data, detail);
+
+ expect(result).toEqual({
+ event: 'test',
+ name: 'John',
+ });
+ });
+
+ it('should resolve deeply nested $detail.* placeholder', () => {
+ const data = { city: '$detail.address.location.city' };
+ const detail = { address: { location: { city: 'Paris' } } };
+
+ const result = resolveDetailPlaceholders(data, detail);
+
+ expect(result).toEqual({ city: 'Paris' });
+ });
+
+ it('should return undefined for missing paths', () => {
+ const data = { email: '$detail.missing' };
+ const detail = { other: 'value' };
+
+ const result = resolveDetailPlaceholders(data, detail);
+
+ expect(result).toEqual({ email: undefined });
+ });
+
+ it('should return undefined when path traversal hits a non-object value', () => {
+ const data = { value: '$detail.foo.bar.baz' };
+ const detail = { foo: 'primitive' }; // foo is a string, not an object
+
+ const result = resolveDetailPlaceholders(data, detail);
+
+ expect(result).toEqual({ value: undefined });
+ });
+
+ it('should preserve non-placeholder values', () => {
+ const data = { event: 'test', static: 'value', number: 42 };
+ const detail = {};
+
+ const result = resolveDetailPlaceholders(data, detail);
+
+ expect(result).toEqual({
+ event: 'test',
+ static: 'value',
+ number: 42,
+ });
+ });
+
+ it('should resolve placeholders in nested objects', () => {
+ const data = {
+ event: 'test',
+ user: {
+ email: '$detail.email',
+ name: '$detail.name',
+ },
+ };
+ const detail = { email: 'test@example.com', name: 'John' };
+
+ const result = resolveDetailPlaceholders(data, detail);
+
+ expect(result).toEqual({
+ event: 'test',
+ user: {
+ email: 'test@example.com',
+ name: 'John',
+ },
+ });
+ });
+
+ it('should preserve arrays', () => {
+ const data = { items: ['a', 'b', 'c'] };
+ const detail = {};
+
+ const result = resolveDetailPlaceholders(data, detail);
+
+ expect(result).toEqual({ items: ['a', 'b', 'c'] });
+ });
+});
diff --git a/packages/tests/index.spec.ts b/packages/tests/index.spec.ts
index 151da8b6..b7c69dcc 100644
--- a/packages/tests/index.spec.ts
+++ b/packages/tests/index.spec.ts
@@ -64,8 +64,12 @@ test('components exports', () => {
"Sticky",
"Tabs",
"Target",
+ "Track",
+ "TrackContext",
"Transition",
"animationScrollWithEase",
+ "getTrackDispatcher",
+ "setTrackDispatcher",
"withDeprecation",
"withScrollAnimationDebug",
"withTransition",
diff --git a/packages/ui/Track/Track.ts b/packages/ui/Track/Track.ts
new file mode 100644
index 00000000..56b36b24
--- /dev/null
+++ b/packages/ui/Track/Track.ts
@@ -0,0 +1,134 @@
+import { Base, getClosestParent } from '@studiometa/js-toolkit';
+import type { BaseProps, BaseConfig } from '@studiometa/js-toolkit';
+import { TrackContext } from './TrackContext.js';
+import { TrackEvent } from './TrackEvent.js';
+import { getTrackDispatcher } from './dispatcher.js';
+
+export interface TrackProps extends BaseProps {
+ $options: {
+ threshold: number;
+ };
+}
+
+/**
+ * Track class.
+ *
+ * A declarative component for analytics tracking, compatible with GTM/dataLayer,
+ * GA4, Segment, and custom backends.
+ *
+ * @link https://ui.studiometa.dev/components/Track/
+ *
+ * @example
+ * ```html
+ *
+ *
+ * Subscribe
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ * Product Card
+ *
+ * ```
+ */
+export class Track extends Base {
+ static config: BaseConfig = {
+ name: 'Track',
+ options: {
+ threshold: {
+ type: Number,
+ default: 0.5,
+ },
+ },
+ };
+
+ /**
+ * @private
+ */
+ __trackEvents?: Set;
+
+ /**
+ * Get all TrackEvent instances parsed from data-track:* attributes.
+ */
+ get trackEvents(): Set {
+ if (this.__trackEvents) {
+ return this.__trackEvents;
+ }
+
+ this.__trackEvents = new Set();
+
+ // Parse data-track:* attributes
+ for (let i = 0; i < this.$el.attributes.length; i++) {
+ const attr = this.$el.attributes[i];
+ if (attr.name.startsWith('data-track:')) {
+ const eventDefinition = attr.name.slice(11); // Remove 'data-track:'
+ try {
+ const data = JSON.parse(attr.value);
+ this.__trackEvents.add(new TrackEvent(this, eventDefinition, data));
+ } catch (err) {
+ this.$warn(`Invalid JSON in ${attr.name}:`, err);
+ }
+ }
+ }
+
+ return this.__trackEvents;
+ }
+
+ /**
+ * Get context data from closest TrackContext parent.
+ */
+ get contextData(): Record {
+ const context = getClosestParent(this, TrackContext);
+ return context?.$options.data ?? {};
+ }
+
+ /**
+ * Dispatch tracking data (merged with context).
+ *
+ * @param data - The tracking data to dispatch
+ * @param event - The optional DOM event that triggered the dispatch
+ */
+ dispatch(data: Record, event?: Event): void {
+ const dispatcher = getTrackDispatcher();
+ const mergedData = { ...this.contextData, ...data };
+ dispatcher(mergedData, event);
+ }
+
+ /**
+ * Mounted lifecycle hook.
+ */
+ mounted() {
+ for (const trackEvent of this.trackEvents) {
+ if (trackEvent.event === 'mounted') {
+ // Dispatch immediately on mount
+ this.dispatch(trackEvent.data);
+ } else if (trackEvent.event === 'view') {
+ // IntersectionObserver for impression tracking
+ trackEvent.attachViewEvent();
+ } else {
+ // Standard DOM event
+ trackEvent.attachEvent();
+ }
+ }
+ }
+
+ /**
+ * Destroyed lifecycle hook.
+ */
+ destroyed() {
+ for (const trackEvent of this.trackEvents) {
+ trackEvent.detachEvent();
+ }
+ }
+}
diff --git a/packages/ui/Track/TrackContext.ts b/packages/ui/Track/TrackContext.ts
new file mode 100644
index 00000000..f7d08f4f
--- /dev/null
+++ b/packages/ui/Track/TrackContext.ts
@@ -0,0 +1,42 @@
+import { Base } from '@studiometa/js-toolkit';
+import type { BaseProps, BaseConfig } from '@studiometa/js-toolkit';
+
+export interface TrackContextProps extends BaseProps {
+ $options: {
+ data: Record;
+ };
+}
+
+/**
+ * TrackContext class.
+ *
+ * Provides hierarchical context data that is merged into child Track components.
+ *
+ * @link https://ui.studiometa.dev/components/Track/js-api.html#trackcontext
+ *
+ * @example
+ * ```html
+ *
+ *
+ *
+ * Add to Cart
+ *
+ *
+ *
+ * ```
+ */
+export class TrackContext extends Base {
+ static config: BaseConfig = {
+ name: 'TrackContext',
+ options: {
+ data: {
+ type: Object,
+ default: () => ({}),
+ },
+ },
+ };
+}
diff --git a/packages/ui/Track/TrackEvent.ts b/packages/ui/Track/TrackEvent.ts
new file mode 100644
index 00000000..31f8f1f6
--- /dev/null
+++ b/packages/ui/Track/TrackEvent.ts
@@ -0,0 +1,214 @@
+import { debounce, throttle } from '@studiometa/js-toolkit/utils';
+import type { Track } from './Track.js';
+
+export type Modifier = 'prevent' | 'stop' | 'once' | 'passive' | 'capture' | 'debounce' | 'throttle';
+
+export interface ParsedEvent {
+ event: string;
+ modifiers: Modifier[];
+ debounceDelay: number;
+ throttleDelay: number;
+}
+
+/**
+ * Parse an event definition string into its components.
+ *
+ * @link https://ui.studiometa.dev/components/Track/js-api.html#event-modifiers
+ *
+ * @param eventDefinition - Event string like "click.prevent.stop" or "input.debounce300"
+ * @returns Parsed event with modifiers and timing delays
+ *
+ * @example
+ * ```ts
+ * parseEventDefinition('click.prevent.stop');
+ * // { event: 'click', modifiers: ['prevent', 'stop'], debounceDelay: 0, throttleDelay: 0 }
+ *
+ * parseEventDefinition('input.debounce500');
+ * // { event: 'input', modifiers: ['debounce'], debounceDelay: 500, throttleDelay: 0 }
+ * ```
+ */
+export function parseEventDefinition(eventDefinition: string): ParsedEvent {
+ const [event, ...rawModifiers] = eventDefinition.split('.');
+
+ let debounceDelay = 0;
+ let throttleDelay = 0;
+ const modifiers: Modifier[] = [];
+
+ for (const mod of rawModifiers) {
+ if (mod.startsWith('debounce')) {
+ modifiers.push('debounce');
+ debounceDelay = parseInt(mod.replace('debounce', '') || '300', 10);
+ } else if (mod.startsWith('throttle')) {
+ modifiers.push('throttle');
+ throttleDelay = parseInt(mod.replace('throttle', '') || '16', 10);
+ } else {
+ modifiers.push(mod as Modifier);
+ }
+ }
+
+ return { event, modifiers, debounceDelay, throttleDelay };
+}
+
+/**
+ * Resolve `$detail.*` placeholders in tracking data with values from event.detail.
+ *
+ * @link https://ui.studiometa.dev/components/Track/js-api.html#custom-events
+ *
+ * @param data - The tracking data object
+ * @param detail - The event.detail object from a CustomEvent
+ * @returns New object with placeholders resolved
+ *
+ * @example
+ * ```ts
+ * resolveDetailPlaceholders(
+ * { event: 'form_submit', email: '$detail.email', name: '$detail.user.name' },
+ * { email: 'test@example.com', user: { name: 'John' } }
+ * );
+ * // { event: 'form_submit', email: 'test@example.com', name: 'John' }
+ * ```
+ */
+export function resolveDetailPlaceholders(
+ data: Record,
+ detail: Record,
+): Record {
+ const result: Record = {};
+
+ for (const [key, value] of Object.entries(data)) {
+ if (typeof value === 'string' && value.startsWith('$detail.')) {
+ const path = value.slice(8); // Remove '$detail.'
+ result[key] = getNestedValue(detail, path);
+ } else if (value && typeof value === 'object' && !Array.isArray(value)) {
+ result[key] = resolveDetailPlaceholders(value as Record, detail);
+ } else {
+ result[key] = value;
+ }
+ }
+
+ return result;
+}
+
+/**
+ * Get a nested value from an object using a dot-separated path.
+ */
+function getNestedValue(obj: Record, path: string): unknown {
+ return path.split('.').reduce((current: unknown, key) => {
+ if (current && typeof current === 'object') {
+ return (current as Record)[key];
+ }
+ return undefined;
+ }, obj);
+}
+
+/**
+ * TrackEvent handles DOM event binding for the Track component.
+ *
+ * @link https://ui.studiometa.dev/components/Track/js-api.html#events
+ */
+export class TrackEvent {
+ track: Track;
+ event: string;
+ modifiers: Modifier[];
+ data: Record;
+ debounceDelay: number;
+ throttleDelay: number;
+
+ private handler: EventListener;
+ private observer?: IntersectionObserver;
+
+ constructor(track: Track, eventDefinition: string, data: Record) {
+ this.track = track;
+ this.data = data;
+
+ const { event, modifiers, debounceDelay, throttleDelay } = parseEventDefinition(eventDefinition);
+ this.event = event;
+ this.modifiers = modifiers;
+ this.debounceDelay = debounceDelay;
+ this.throttleDelay = throttleDelay;
+
+ // Build handler with timing modifiers
+ let handler: EventListener = (event: Event) => this.handleEvent(event);
+
+ if (modifiers.includes('debounce')) {
+ handler = debounce(handler, debounceDelay) as EventListener;
+ } else if (modifiers.includes('throttle')) {
+ handler = throttle(handler, throttleDelay) as EventListener;
+ }
+
+ this.handler = handler;
+ }
+
+ /**
+ * Handle the DOM event and dispatch tracking data.
+ */
+ handleEvent(event: Event) {
+ const { modifiers, data, track } = this;
+
+ if (modifiers.includes('prevent')) {
+ event.preventDefault();
+ }
+
+ if (modifiers.includes('stop')) {
+ event.stopPropagation();
+ }
+
+ // Handle CustomEvent detail merging
+ let finalData = data;
+ if (event instanceof CustomEvent && event.detail) {
+ // Check for .detail modifier (merge full detail)
+ if (modifiers.includes('detail' as Modifier)) {
+ finalData = { ...data, ...event.detail };
+ } else {
+ // Resolve $detail.* placeholders
+ finalData = resolveDetailPlaceholders(data, event.detail);
+ }
+ }
+
+ track.dispatch(finalData, event);
+ }
+
+ /**
+ * Attach the event listener for standard DOM events.
+ */
+ attachEvent() {
+ const { event, modifiers, handler, track } = this;
+
+ track.$el.addEventListener(event, handler, {
+ capture: modifiers.includes('capture'),
+ once: modifiers.includes('once'),
+ passive: modifiers.includes('passive'),
+ });
+ }
+
+ /**
+ * Attach IntersectionObserver for the "view" event.
+ */
+ attachViewEvent() {
+ const { modifiers, track, data } = this;
+ const threshold = track.$options.threshold;
+
+ this.observer = new IntersectionObserver(
+ (entries) => {
+ for (const entry of entries) {
+ if (entry.isIntersecting && entry.intersectionRatio >= threshold) {
+ track.dispatch(data);
+
+ if (modifiers.includes('once')) {
+ this.observer?.disconnect();
+ }
+ }
+ }
+ },
+ { threshold },
+ );
+
+ this.observer.observe(track.$el);
+ }
+
+ /**
+ * Detach the event listener.
+ */
+ detachEvent() {
+ this.track.$el.removeEventListener(this.event, this.handler);
+ this.observer?.disconnect();
+ }
+}
diff --git a/packages/ui/Track/dispatcher.ts b/packages/ui/Track/dispatcher.ts
new file mode 100644
index 00000000..a312b566
--- /dev/null
+++ b/packages/ui/Track/dispatcher.ts
@@ -0,0 +1,58 @@
+declare global {
+ interface Window {
+ dataLayer?: Record[];
+ }
+}
+
+export type TrackDispatcher = (data: Record, event?: Event) => void;
+
+let dispatcher: TrackDispatcher | null = null;
+
+/**
+ * Default dispatcher: push to dataLayer (GTM).
+ * GTM handles consent at tag level via Axeptio/CMP triggers.
+ */
+function defaultDispatcher(data: Record) {
+ if (typeof window !== 'undefined') {
+ window.dataLayer = window.dataLayer || [];
+ window.dataLayer.push(data);
+ }
+}
+
+/**
+ * Set a custom dispatcher function.
+ *
+ * @link https://ui.studiometa.dev/components/Track/js-api.html#custom-dispatcher
+ *
+ * @param fn - The dispatcher function, or null to reset to default.
+ *
+ * @example
+ * ```js
+ * import { setTrackDispatcher } from '@studiometa/ui';
+ *
+ * // Send to GA4 directly
+ * setTrackDispatcher((data, event) => {
+ * gtag('event', data.event, data);
+ * });
+ *
+ * // Send to multiple destinations
+ * setTrackDispatcher((data) => {
+ * window.dataLayer.push(data);
+ * fetch('/api/analytics', { method: 'POST', body: JSON.stringify(data) });
+ * });
+ *
+ * // Reset to default (dataLayer.push)
+ * setTrackDispatcher(null);
+ * ```
+ */
+export function setTrackDispatcher(fn: TrackDispatcher | null): void {
+ dispatcher = fn;
+}
+
+/**
+ * Get the current dispatcher function.
+ * @internal
+ */
+export function getTrackDispatcher(): TrackDispatcher {
+ return dispatcher ?? defaultDispatcher;
+}
diff --git a/packages/ui/Track/index.ts b/packages/ui/Track/index.ts
new file mode 100644
index 00000000..8295d632
--- /dev/null
+++ b/packages/ui/Track/index.ts
@@ -0,0 +1,3 @@
+export * from './Track.js';
+export * from './TrackContext.js';
+export * from './dispatcher.js';
diff --git a/packages/ui/index.ts b/packages/ui/index.ts
index c377ee75..99e72a6f 100644
--- a/packages/ui/index.ts
+++ b/packages/ui/index.ts
@@ -24,4 +24,5 @@ export * from './Sentinel/index.js';
export * from './Slider/index.js';
export * from './Sticky/index.js';
export * from './Tabs/index.js';
+export * from './Track/index.js';
export * from './Transition/index.js';