Open source · MIT · 6 KB gzipped

Stop writing
track('clicked_cta')

Add one script tag. zerotag records every click, scroll, form and route change, and gives each one a name a human would use, like Clicked "Start free trial" in Pricing. You never write a tracking plan.

<script defer src="https://t.p001.ai/zt.js" data-site="yoursite.com"></script>

This page is running zerotag right now

Nothing here has an event handler for analytics. Click things, fill in the form, scroll. The feed on the right is what gets sent.

Team plan

$0

Unlimited sites. Your data stays in your storage.

Download pricing PDF

Join the waitlist

Type anything. The value never leaves your browser, only the field name does.

A broken button

This one does nothing. Click it three times fast.

Before

// checkout.tsx
<Button onClick={() => {
  analytics.track('checkout_cta_clicked', {
    location: 'pricing_page',
    variant: plan.id,
  })
  startCheckout()
}}>

// ...repeated in 214 other components,
// renamed twice, and half of them broke
// in the last redesign.

After

<script defer
  src="https://t.p001.ai/zt.js"
  data-site="yoursite.com"></script>

// That's it. Rename a button and the
// label follows. Ship a new page and
// it's already tracked.

How it names things

When you click the span inside a button, zerotag walks up to the control a person would recognize, reads its name the way a screen reader would, and finds the section around it.

<section id="pricing">
  <h2>Pricing</h2>
  <button class="btn css-1a2b3c">
    <span>Start free trial</span>  ← you clicked here
  </button>
</section>
{
  "name": "Clicked \"Start free trial\" in Pricing",
  "kind": "button",
  "label": "Start free trial",
  "section": "Pricing",
  "selector": "section#pricing>button.btn"
}
  1. Find the control. Links, buttons, inputs, ARIA roles and anything with onclick.
  2. Name it. data-zt, then aria-label, the field's <label>, visible text, image alt, title.
  3. Place it. The nearest section, nav, form or dialog, named by its heading or aria-label.
  4. Keep selectors stable. Hashed CSS-in-JS class names are dropped, so the same button keeps the same selector after a redeploy.

Captured without a line of code

Clicks

Typed as button, link, outbound link, download, email, phone, submit, checkbox, tab.

Pageviews

On load and on every client-side route change. Next.js, React Router, Vue, SvelteKit, Astro.

Scroll depth

25, 50, 75, 90 and 100 percent, once each per page.

Sections seen

A section counts as seen after 1 second at 40 percent visible.

Forms

Which fields people touched, submits, and abandons. Values are never read.

Rage clicks

Three clicks in under a second on the same spot.

Dead clicks

A button was clicked and the page didn't change within a second.

Engaged time

Only counts time the tab was visible and someone moved, typed or scrolled in the last 30 seconds.

Video and audio

Play, pause and ended, with the timestamp.

Copy

Which section people copy from. Not what they copied.

JS errors

Uncaught errors with file and line, next to the click that caused them.

Campaigns

UTM parameters, referrer, country, city, device, browser, OS.

Private by default

Autocapture only works if you can trust what it picks up. These are defaults, not settings you have to find.

Built to take a traffic spike

The browser batches events and sends them with sendBeacon as plain text, so there's no CORS preflight and nothing is lost when a tab closes. One Edge Function receives them, answers in milliseconds, and writes to storage in the background.

Your subdomain, your data

Point t.yoursite.com at your own collector. Requests stay first-party, ad blockers leave them alone, and events land in storage you control.

Deploy to Vercel

Questions

Can I still send my own events?

Yes. zerotag.track('Upgraded', {seats: 5}) works, and zerotag.set({plan: 'pro'}) attaches properties to everything after it. Most teams find they need a handful, not hundreds.

What if a label comes out wrong?

Add data-zt="Better name" to the element. That wins over everything else. Usually the real fix is an aria-label, which also helps screen reader users.

Will it slow my site down?

The script is about 6 KB gzipped and loads with defer. Listeners are passive and attached once on document, so it doesn't touch your components.

Does it work with single-page apps?

Yes. It hooks history.pushState, replaceState and popstate, so each route change sends a page leave and a new pageview.

How much traffic can it take?

The collector is stateless and scales per request on the edge. The limit is whatever storage you point it at. Redis is fine for most sites; switch to Tinybird or a webhook into your warehouse when you outgrow it.

One line. Then go build the product.

<script defer src="https://t.p001.ai/zt.js" data-site="yoursite.com"></script>