> ## Documentation Index
> Fetch the complete documentation index at: https://docs.traceten.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Scroll tracking

> Fire a goal when a visitor scrolls a section into view, with one HTML attribute.

## What this lets you do

Count how many visitors actually reached your pricing table, your testimonials, or the bottom of a long post, and then use that count as a funnel step.

Add one attribute to the element:

```html theme={null}
<section id="pricing" data-traceten-scroll="scroll_to_pricing">
  <!-- your pricing table -->
</section>
```

The `scroll_to_pricing` goal fires the moment any part of that section enters the viewport. No JavaScript, no deploy beyond the HTML edit.

## Before you start

The Traceten snippet has to be installed on the page. See the [install overview](/install/overview).

The name follows the [goal name rules](/goals/overview#goal-name-rules): lowercase letters, digits and underscores, starting with a letter. An invalid or [reserved](/goals/overview#reserved-names) name is ignored silently.

## Properties

`data-traceten-goal-*` attributes work here too, on the same element:

```html theme={null}
<section
  data-traceten-scroll="scroll_to_pricing"
  data-traceten-goal-variant="b"
  data-traceten-goal-page-type="landing"
></section>
```

fires `scroll_to_pricing` with `{ "variant": "b", "page_type": "landing" }`. The same [property rules and limits](/goals/overview#property-rules) apply, including the fact that **property keys and values are both stored and both read back** ([detail](/goals/overview#what-happens-to-property-keys-and-values-exactly)).

## Once per element

A scroll goal fires **at most once per element, per page load**. Scroll past the section, scroll back up, scroll down to it again: one goal.

That guarantee is per element, and an element is a DOM node. Navigating away and back within a single-page app usually rebuilds the section as a new node, and a new node counts again: two pageviews, two completions, which is the right answer. What cannot happen is the same node counting twice.

This is deliberate and it is not just tidiness. Scroll goals are the highest-volume kind of goal, and every goal is a billable event on your plan. A goal that re-fired every time a section re-entered the viewport would charge you several times for one visitor reading one page, and would make the number meaningless as a funnel step.

<Warning>
  The cap is per element, not per goal name. If you put the same
  `data-traceten-scroll="scroll_to_pricing"` on two elements, say a mobile variant and a desktop
  variant of one section, both fire on the same pageview and both are billed, even though the
  visitor only ever sees one of them. Put the attribute on a single element and style that one, or
  give the variants different names.
</Warning>

### Single-page apps

**A scroll goal fires at most once per element, for as long as the page is open.** Not once per URL: once per element. Traceten remembers which elements have fired and never arms one of them a second time, so a visitor who reaches your pricing section, navigates elsewhere and comes back is one completion, not two.

What a route change does is arm every goal element that has **not** fired yet, including the ones the new route just rendered. In practice that is the normal case: most frameworks build fresh DOM nodes for a new route, so a `scroll_to_pricing` section on `/pricing` is a different element from the one on `/product` and counts on its own.

The one case where this is visible: if your app keeps the very same DOM node mounted across routes (a shared layout, a persistent sidebar), that node fires once and then not again, however many times the URL changes around it.

A goal counts whether the visitor scrolled to the section or the router put it in front of them. Navigating to a route whose section is above the fold records the completion on arrival, with no scrolling, exactly as a fresh page load of that same URL would. The two entry paths agree by design.

A pageview for the same URL changes nothing at all. If your app calls `traceten("pageview")` by hand on a route that did not actually change, no goal fires a second time.

## Known limitations

**Elements added after the page loads are picked up on the next route change, not immediately.** Traceten scans for `data-traceten-scroll` elements when the page loads and again on each single-page-app route change. It does not watch the DOM continuously, because a document-wide mutation observer on every customer page is a cost we are not willing to put on your site for this feature.

This includes the case where a route changes, paints a skeleton, and fills in the real content a second later: the scan runs against the skeleton and finds nothing, so that route's new section is armed on the next route change rather than on this one. Anything already armed keeps working throughout, including sections that were waiting to be scrolled to.

For a scroll goal injected into an already-loaded page without a route change (a lazy-loaded section, a modal, content revealed by a "load more" button), call `traceten.track("your_goal_name")` directly at the moment the content appears.

**Any visible sliver counts as "in view."** The goal fires as soon as any part of the element intersects the viewport, not when it is fully visible or centred. If you want "reached the bottom of the article," put the attribute on a small element at the bottom rather than on the article itself.

**At most 200 scroll elements are armed, in document order.** The cut is taken before anything else, on every scan, so an element that sits past the 200th in the document is never armed at all, for the life of the page load. It is not "skipped this time and picked up later". A page with more than 200 scroll goals is almost always a selector mistake.

## Browser support

Scroll goals use the browser's `IntersectionObserver` API, which every current browser has had for years.

Where it is missing, the feature does nothing: no goal, no error, no fallback. Traceten does not ship a polyfill for it, because the snippet is capped at 10KB and a polyfill would cost a meaningful share of that budget to serve a fraction of a percent of traffic. Everything else on the page, including click goals and pageviews, is unaffected.

## Verify it worked

1. Open the page with devtools, Network tab, filtered to `conversions`.
2. Scroll the section into view.
3. One `POST` to `https://ingest.traceten.com/v1/conversions` should appear, with your goal name in the body.

If nothing happens, check that the element is actually reachable by scrolling (a section inside a container with `display: none` never intersects), then work through [when a goal is not recorded](/goals/overview#when-a-goal-is-not-recorded). The observer is installed only once a consent tool on the page has answered, so a scroll before that point records nothing.

## What is stored, and for how long

A scroll goal is a conversion record like any other goal: retained for 365 days, plus a visitor step row retained for 730 days. See [data collected](/privacy/data-collected#data-retention) for the full picture, and [what happens to property keys and values](/goals/overview#what-happens-to-property-keys-and-values-exactly) before you attach anything to one.

## Next

* [Funnels](/goals/funnels): use `scroll_to_pricing` as a step and see who continued.
* [Goals](/goals/overview): the other three ways to fire one.
