UI

Analytics

trackIntents() turns the intent on every element into one event shape: presses, submits, toggles, opens and closes, named by purpose. Prongs decides the shape; you decide where events go.

What gets captured

Every Prongs component puts its intent on its element as data-intent. trackIntents() listens on the document and turns what people do with those elements into one event shape, the same on every page. Prongs decides the shape and how events are captured; you decide where they go, which ones, consent and sampling.

  • press

    A click, or a keyboard press, on an action, navigation or choice. Disabled ones are skipped.

  • toggle

    A press on something that stays on or off (aria-pressed), with its state after the press.

  • submit

    A form sent, named by the form’s intent: layout.form, or one of your own.

  • open

    A dialog or popover opening, named by its intent: dialog.confirm, popover.menu.

  • close

    The same closing again, however it closed: Escape, a click outside, or your code.

TS
type IntentEvent = {
type: 'press' | 'submit' | 'toggle' | 'open' | 'close';
intent: string; // what the element is for: "action.delete"
within: string[]; // the intents around it, innermost first, at most 5
path: string; // location.pathname, never the query string
state?: 'on' | 'off'; // toggles only: pressed after the press
time: number; // milliseconds since the epoch
};

A press counts for the nearest action, navigation or choice around what was clicked, so a click on the icon or heading inside a link counts for the link. The listeners run before your own handlers, so within still has the dialog a button was in, even when pressing it closes the dialog. A press on the delete button of a confirm dialog:

JSON
{
"type": "press",
"intent": "action.delete",
"within": ["layout.actions", "dialog.confirm", "layout.page"],
"path": "/projects/42/settings",
"time": 1760212800000
}

Set it up

Terminal
npx prongs add analytics

Copies trackIntents() into components/prongs/analytics.ts. It is your file, with no dependencies and no vendor SDK. Call it once on the client, in the root layout or a client provider. It is safe to import during server rendering, where it does nothing.

TSX
// app/analytics.tsx
'use client';

import { useEffect } from 'react';
import { trackIntents } from '@/components/prongs/analytics';

export function Analytics() {
// trackIntents returns the function that stops it, which is what an effect cleans up with.
useEffect(() => trackIntents((event) => console.log(event)), []);
return null;
}

// app/layout.tsx: render <Analytics /> once, inside <body>.

It reads the attribute the components write, so intentAttribute in components/prongs/config.ts has to stay 'always'. With 'development', production pages carry no intents and nothing is captured.

Recipes

Each recipe is the callback and nothing else. Load the tool the way its own docs say.

Google Tag Manager and GA4
TS
trackIntents((event) => {
window.dataLayer = window.dataLayer ?? [];
window.dataLayer.push({ event: 'intent', ...event, within: event.within.join(' > ') });
});
// In Tag Manager: a Custom Event trigger on "intent", Data Layer Variables for
// type, intent and within, and a GA4 event tag that sends them as parameters.
PostHog
TS
import posthog from 'posthog-js';

posthog.init(KEY, { autocapture: false }); // autocapture collects element text; this does not
trackIntents((event) => posthog.capture(`intent ${event.type}`, event));
Segment
TS
trackIntents((event) => analytics.track(`Intent ${event.type}`, event));
Plausible
TS
// Plausible properties are strings, and every event counts against your plan: keep presses and submits.
trackIntents(
(event) => window.plausible?.('Intent', { props: { type: event.type, intent: event.intent, within: event.within.join(' > ') } }),
{ include: (event) => event.type === 'press' || event.type === 'submit' }
);

What it is good for

Funnels that survive a redesign. A step defined as press action.start keeps counting when the button moves, changes colour or gets new copy. Selectors on classes or text break on each of those; an intent only changes when the button stops doing that job.

Text
// Sign-up funnel, as steps your analytics tool can count
press action.start path /
submit layout.form path /signup
press action.next path /signup/team within layout.form

Feature usage by intent. Count presses per intent to see what gets used, and group by within to see where: the same action.share pressed in a popover.menu or a layout.toolbar. An intent names a kind of element, not one button: action.save is on every form. Add path to count one screen, and give a step you track closely an intent of its own.

Joining a press to the error it caused. Responses from fail() carry code, the error intent. Send it with the press that led to it, as below, and a dashboard can count which presses end in conflict.state or record.missing without a mapping table. The link is the one you write: Prongs does not connect the two on its own.

TS
const response = await fetch(`/api/projects/${id}`, { method: 'DELETE' });
if (!response.ok) {
const { code } = await response.json(); // "conflict.state", the intent fail() was called with
posthog.capture('intent error', { code, after: 'action.delete' });
}

Dead clicks. Clicks on things that do nothing, like an image or a topic people expect to open, show what the page promises and does not deliver. A second tracker with other prefixes reports them, leaving out anything inside a real action.

TS
// A second tracker for clicks on things that are not actions: an image, a heading, a topic.
const pressable = /^(action|nav|choice)\./;
trackIntents((event) => posthog.capture('dead click', event), {
prefixes: ['media.', 'heading.', 'text.'],
include: (event) => !event.within.some((intent) => pressable.test(intent)),
});

A/B targeting. Experiment tools can target [data-intent="action.start"] to change copy, and measure the result with the same event. Add the variant in your callback.

TS
trackIntents((event) => posthog.capture(`intent ${event.type}`, { ...event, variant: getVariant('hero-copy') }));

From events to decisions

The events are only worth collecting if they change what you build. Two examples, with made-up numbers, of reading them and deciding something.

A confirm people back out of. Count what happens after each dialog.confirm opens: the press inside it, or a close with none.

Text
// Delete confirmations on /projects/:id/settings, one month (made-up numbers)
open dialog.confirm 1,240
press action.delete within dialog.confirm 410 33%
press action.cancel within dialog.confirm 610 49%
close dialog.confirm with no press inside 220 18%

Two in three people who open it leave without deleting. If they came for something else, like archiving a project, that is a missing action.archive. If they were unsure, the dialog should say what goes and what stays. Either way the next version is measured by the same four lines.

A feature in the wrong place. Group presses on one intent by where they happened.

Text
// Presses on action.share, grouped by the first intent in within
within popover.menu 3,920
within layout.toolbar 880

Sharing is mostly found in the menu, so the toolbar button earns little of its space. Before moving it, check the opposite reading: the toolbar may be quiet because it is hidden on small screens. Split by path, or by device in your tool, to tell them apart.

What the events cannot tell you. There is no event for an element being seen, only for it being used, so a button nobody noticed and a button nobody wanted look the same. Rates need a denominator from elsewhere: page views from your analytics tool, or the open of the dialog around it. Events carry no user, so per-person questions, like how many people turn a setting on, depend on the identity your tool adds.

Never for styling

data-intent is for finding elements, not styling them. A selector on it in CSS or in a class would be a second place that decides how something looks, and prongs check reports it as intent-selector. More on targeting elements.