diff --git a/packages/docs/components/Track/index.md b/packages/docs/components/Track/index.md
new file mode 100644
index 00000000..a9645dad
--- /dev/null
+++ b/packages/docs/components/Track/index.md
@@ -0,0 +1,49 @@
+---
+badges: [JS]
+---
+
+# Track
+
+Use the `Track` component to publish [Shopify analytics events](https://shopify.dev/docs/api/web-pixels-api/emitting-data) when a section enters the viewport or is clicked.
+
+## Table of content
+
+- [JS API](./js-api.md)
+
+## Usage
+
+Register the component in your JavaScript app and use it in your templates. The component will publish a viewed event the first time its root element enters the viewport, and a clicked event on every click, with the payload defined in the `
+ Click me
+
+```
+
+The published events default to `bento_section_viewed` and `bento_section_clicked` and can be configured with the [`viewedEvent` and `clickedEvent` options](./js-api.md#options):
+
+```liquid
+
+ ...
+
+```
+
+::: warning Shopify only
+This component relies on the `Shopify.analytics.publish` API which is only available on Shopify storefronts. A warning will be displayed in debug mode when the API is not available.
+:::
diff --git a/packages/docs/components/Track/js-api.md b/packages/docs/components/Track/js-api.md
new file mode 100644
index 00000000..b4a1b409
--- /dev/null
+++ b/packages/docs/components/Track/js-api.md
@@ -0,0 +1,72 @@
+---
+title: Track JS API
+outline: deep
+---
+
+# JS API
+
+The `Track` class extends the [`Base` class](https://js-toolkit.studiometa.dev/api/) with the [`withIntersectionObserver` decorator](https://js-toolkit.studiometa.dev/api/decorators/withIntersectionObserver.html).
+
+## Refs
+
+### `payload`
+
+A `
+```
+
+## Options
+
+### `viewedEvent`
+
+- Type: `string`
+- Default: `'bento_section_viewed'`
+
+The name of the event published the first time the root element enters the viewport.
+
+### `clickedEvent`
+
+- Type: `string`
+- Default: `'bento_section_clicked'`
+
+The name of the event published when the root element is clicked. In addition to the payload, the published data contains:
+
+- `url`: the current page URL
+- `target`: the lowercase tag name of the clicked element
+- `target_content`: the text content of the clicked element, trimmed and truncated to 100 characters
+
+### `intersectionObserver`
+
+- Type: `IntersectionObserverInit`
+- Default: `{ threshold: 0 }`
+
+Options for the `IntersectionObserver` instance used to detect the visibility of the root element.
+
+## Properties
+
+### `payload`
+
+- Type: `Record`
+
+The payload parsed from the [`payload` ref](#payload).
+
+### `hasBeenViewed`
+
+- Type: `boolean`
+
+Whether the viewed event has already been published.
+
+## Methods
+
+### `publish`
+
+Publish an event with the `Shopify.analytics.publish` API. A warning is displayed in debug mode when the API is not available.
+
+**Parameters**
+
+- `name` (`string`): the name of the event
+- `payload` (`Record`): the data sent with the event
diff --git a/packages/tests/Track/Track.spec.ts b/packages/tests/Track/Track.spec.ts
new file mode 100644
index 00000000..becddd1a
--- /dev/null
+++ b/packages/tests/Track/Track.spec.ts
@@ -0,0 +1,125 @@
+import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from 'vitest';
+import { Track } from '@studiometa/ui';
+import {
+ h,
+ destroy,
+ intersectionObserverAfterEachCallback,
+ intersectionObserverBeforeAllCallback,
+ mockIsIntersecting,
+ mount,
+} from '#test-utils';
+
+beforeAll(() => {
+ intersectionObserverBeforeAllCallback();
+});
+
+const publish = vi.fn();
+
+beforeEach(() => {
+ // @ts-expect-error the `Shopify` global is only available on Shopify storefronts.
+ window.Shopify = { analytics: { publish } };
+});
+
+afterEach(() => {
+ intersectionObserverAfterEachCallback();
+ // @ts-expect-error the `Shopify` global is only available on Shopify storefronts.
+ delete window.Shopify;
+ publish.mockClear();
+});
+
+async function getContext({
+ payload = JSON.stringify({ name: 'bloc-name', template: 'index' }),
+ attributes = {},
+}: { payload?: string; attributes?: Record } = {}) {
+ const script = h('script', { dataRef: 'payload', type: 'application/json' }, [payload]);
+ const link = h('a', { href: '#' }, ['Click me']);
+ const root = h('div', attributes, [script, link]);
+ const track = new Track(root);
+ await mount(track);
+
+ return { root, script, link, track };
+}
+
+describe('The Track component', () => {
+ it('should publish the viewed event with the payload when intersecting for the first time only', async () => {
+ const { root, track } = await getContext();
+
+ await mockIsIntersecting(root, false);
+ expect(publish).not.toHaveBeenCalled();
+
+ await mockIsIntersecting(root, true);
+ expect(publish).toHaveBeenCalledTimes(1);
+ expect(publish).toHaveBeenCalledWith('bento_section_viewed', {
+ name: 'bloc-name',
+ template: 'index',
+ });
+
+ await mockIsIntersecting(root, false);
+ await mockIsIntersecting(root, true);
+ expect(publish).toHaveBeenCalledTimes(1);
+
+ await destroy(track);
+ });
+
+ it('should publish the clicked event with the payload and target infos on click', async () => {
+ const { link, track } = await getContext();
+
+ link.click();
+
+ expect(publish).toHaveBeenCalledTimes(1);
+ expect(publish).toHaveBeenCalledWith('bento_section_clicked', {
+ name: 'bloc-name',
+ template: 'index',
+ url: window.location.href,
+ target: 'a',
+ target_content: 'Click me',
+ });
+
+ await destroy(track);
+ });
+
+ it('should use the configured event names', async () => {
+ const { root, link, track } = await getContext({
+ attributes: {
+ dataOptionViewedEvent: 'custom_viewed',
+ dataOptionClickedEvent: 'custom_clicked',
+ },
+ });
+
+ await mockIsIntersecting(root, true);
+ link.click();
+
+ expect(publish).toHaveBeenNthCalledWith(1, 'custom_viewed', expect.any(Object));
+ expect(publish).toHaveBeenNthCalledWith(2, 'custom_clicked', expect.any(Object));
+
+ await destroy(track);
+ });
+
+ it('should warn and publish an empty payload when the payload JSON is invalid', async () => {
+ const { root, track } = await getContext({ payload: '{ invalid json' });
+ const warn = vi.fn();
+ vi.spyOn(track, '$warn', 'get').mockReturnValue(warn);
+
+ await mockIsIntersecting(root, true);
+
+ expect(warn).toHaveBeenCalledWith('Invalid payload JSON');
+ expect(publish).toHaveBeenCalledWith('bento_section_viewed', {});
+
+ await destroy(track);
+ });
+
+ it('should warn and not fail when the Shopify analytics API is not available', async () => {
+ const { root, track } = await getContext();
+ const warn = vi.fn();
+ vi.spyOn(track, '$warn', 'get').mockReturnValue(warn);
+ // @ts-expect-error the `Shopify` global is only available on Shopify storefronts.
+ delete window.Shopify;
+
+ await mockIsIntersecting(root, true);
+
+ expect(publish).not.toHaveBeenCalled();
+ expect(warn).toHaveBeenCalledWith('The `Shopify.analytics.publish` API is not available');
+
+ await destroy(track);
+ });
+});
diff --git a/packages/tests/index.spec.ts b/packages/tests/index.spec.ts
index 151da8b6..01771274 100644
--- a/packages/tests/index.spec.ts
+++ b/packages/tests/index.spec.ts
@@ -64,6 +64,7 @@ test('components exports', () => {
"Sticky",
"Tabs",
"Target",
+ "Track",
"Transition",
"animationScrollWithEase",
"withDeprecation",
diff --git a/packages/ui/Track/Track.ts b/packages/ui/Track/Track.ts
new file mode 100644
index 00000000..7ad157ec
--- /dev/null
+++ b/packages/ui/Track/Track.ts
@@ -0,0 +1,99 @@
+import { Base, withIntersectionObserver } from '@studiometa/js-toolkit';
+import type { BaseConfig, BaseProps } from '@studiometa/js-toolkit';
+
+export interface TrackProps extends BaseProps {
+ $refs: {
+ payload: HTMLScriptElement;
+ };
+ $options: {
+ viewedEvent: string;
+ clickedEvent: string;
+ };
+}
+
+/**
+ * Track class.
+ *
+ * Publish Shopify analytics events when the root element enters the viewport
+ * for the first time or when it is clicked.
+ * @link https://ui.studiometa.dev/components/Track/
+ */
+export class Track extends withIntersectionObserver(Base, {
+ threshold: 0,
+}) {
+ /**
+ * Config.
+ */
+ static config: BaseConfig = {
+ name: 'Track',
+ refs: ['payload'],
+ options: {
+ viewedEvent: {
+ type: String,
+ default: 'bento_section_viewed',
+ },
+ clickedEvent: {
+ type: String,
+ default: 'bento_section_clicked',
+ },
+ },
+ };
+
+ /**
+ * Whether the viewed event has already been published.
+ */
+ hasBeenViewed = false;
+
+ /**
+ * Payload parsed from the `