A lightweight JavaScript library that measures how deeply users read your content, not just page views. It tracks reading time, scroll depth, and content engagement. There are also add-ons for Google Analytics, Matomo, and visualization.
You spend hours crafting content. Your analytics say 5,000 people visited the page. But how many of them actually read what you wrote? Your analytics tool can't tell you. It counts pageviews and time on page — but a visitor who scrolls to the bottom in 3 seconds looks the same as one who reads every word for 5 minutes.
Kntnt Engagement Metrics answers the question your analytics can't: how much of your content did people actually engage with?
It's a tiny JavaScript library that runs silently on your pages. It watches what visitors do — which paragraphs are visible, how long they linger, how fast they scroll — and from that, it estimates how much they actually read versus how much they merely scrolled past. The results are sent to your analytics platform (Matomo, Google Analytics, or others) so you can see exactly which content truly engages your audience.
The library is open source, lightweight (< 4 KB), has zero dependencies, and is designed to impose near-zero performance overhead on your visitors.
Try the live demo — a long-read article with the core library and overlay add-on running on it. Scroll and read the article. The overlay colour-codes each paragraph by reading state and shows live metrics in a panel at the bottom-right corner. Press Ctrl+Shift+D to toggle the overlay on and off.
You can also run the demo locally by cloning the repository, building the project, and opening demo.html in your browser:
git clone https://github.com/Kntnt/kntnt-engagement-metrics.git
cd kntnt-engagement-metrics
bun install && bun run build
open demo.html # or just double-click the fileThe library distinguishes between two behaviours: reading (the visitor pauses on content long enough to consume it) and scanning (the visitor scrolls content into view but moves on). Here is the process in brief:
- The library finds all content elements matching a CSS selector (default:
<p>tags). - An
IntersectionObservertracks which elements are visible in the viewport. - A lightweight tick loop advances reading for one element at a time — the topmost unfinished visible paragraph — modelling a human reader who reads top to bottom. Reading progress within each element is capped at its visible boundary.
- Reading timers pause during active scrolling and when the browser tab is hidden.
- Metrics are emitted on each tick to registered listeners (analytics add-ons).
The following sections explain each step in detail — enough to understand how the library works without reading the source code.
When the library starts, it searches the page for all HTML elements that match a CSS selector — by default all <p> tags, but you can configure any selector (for instance article :is(h1, h2, h3, p, ul, ol) to include headings and lists). If you have set an exclude selector, any element inside an excluded area (a sidebar, a footer, a table of contents) is filtered out. Elements that are hidden or contain no text are also discarded.
The remaining elements form the content that will be measured, in the order they appear on the page.
Each element gets its own countdown timer. The timer's duration is calculated from the element's character count and the configured reading speed (default: 1 380 characters per minute). A paragraph with 690 characters, for example, gets a 30-second timer. This represents the library's estimate of how long an average reader needs to read that particular element.
The library uses the browser's built-in IntersectionObserver API to monitor which elements are currently visible in the viewport and how much of each element is showing. This is efficient — the browser itself reports visibility changes rather than the library having to check repeatedly.
When an element appears in the viewport for the first time, the library also records how far down the page the visitor has scrolled. This is the scanning depth: the deepest point the visitor has reached.
Five times per second (every 200 ms), the library runs a measurement tick. Each tick looks for the topmost element that is visible and has not yet been fully read, and advances that element's countdown timer by 0.2 seconds. Only one element advances per tick — this models a human reader who finishes one paragraph before moving to the next.
There is one important constraint: reading progress cannot exceed the element's visible boundary. If only half of a paragraph is on screen, the timer advances until 50% of the estimated reading time has elapsed, then waits. Once the visitor scrolls to reveal more of the element, the timer continues. This prevents counting text as read when it is not yet visible.
If the visitor scrolls past a paragraph without pausing, the timer for that element never advances (or advances only partially). The library moves on to the next visible element. If the visitor later scrolls back up, reading resumes from where it left off — progress is never lost.
While the visitor is actively scrolling, reading timers do not advance. The library detects scrolling by measuring scroll speed; once the speed drops below a threshold and remains low for 50 ms, reading resumes. This distinguishes scanning (scrolling through content) from reading (pausing on content).
Timers also pause whenever the browser tab is hidden — if a visitor switches to another tab for five minutes, those five minutes are not counted as reading time.
After each tick, the library computes a snapshot of aggregate metrics from all tracked elements:
- Reading ratio — the fraction of all content characters estimated to have been read. Each element contributes its character count multiplied by its timer's progress (0 to 1). Sum those up, divide by the total character count across all elements, and you get a number between 0 and 1. A reading ratio of 0.75 means roughly 75% of the text has been read.
- Scanning ratio — how far down the page the visitor has scrolled, relative to the total scrollable height. A visitor who scrolls to the very bottom reaches a scanning ratio of 1.
- Reading time — the total estimated seconds of actual reading so far (the sum of elapsed timer time across all elements).
- Content time — the total estimated time it would take to read everything.
- Reading length and content length — characters read and total characters.
- Scanning depth and content depth — pixels scrolled and total scrollable pixels.
The metrics snapshot is passed to all registered add-ons on every tick. The analytics add-ons (Matomo, Google Analytics) do not send an event on every tick — that would flood your analytics platform. Instead, they use configurable thresholds. By default, the Matomo add-on fires reading events at every ten percentage points (10%, 20%, 30%, … 100%), while the GA4 add-on uses a sparser set (10%, 25%, 50%, 75%, 90%, and 100%). Both use the same default scanning thresholds: 25%, 50%, 75%, and 100%.
The defaults differ because the platforms have different constraints. A self-hosted Matomo instance has no event quotas and no sampling, so more granular reporting is simply better. GA4's free tier applies sampling at high event volumes, enforces daily event quotas, and limits the number of custom dimensions per property — fewer thresholds reduce the risk of hitting those limits. Both sets of thresholds are configurable; these are defaults for sites that do not specify their own.
When the reading ratio first crosses a threshold, the add-on fires a single event to your analytics platform — for example, "this visitor has read 25% of the content". Each threshold fires exactly once; subsequent ticks that remain above it do not trigger another event. A fully engaged reader generates a handful of reading events and a few scanning events for the entire page visit.
This incremental reporting also serves as a safeguard against data loss. Browser unload and beforeunload events are unreliable — especially on mobile, where the browser may close a tab without firing them. Because thresholds are reported as they are crossed during the visit, the analytics platform always has data from the last threshold reached, even if the final update never arrives. A visitor who read 73% of the content and then left has already generated an event at 70%.
Once every element has been fully read and the visitor has scrolled to the bottom of the page, both the reading ratio and scanning ratio reach 1. The library then stops its measurement loop and releases its observers and event listeners, leaving zero overhead on the page.
For the full algorithm specification, see docs/algorithm.md. For architecture and design principles, see docs/architecture.md.
The library consists of four packages. You need the core package plus at least one add-on to do something useful with the measurements.
| Package | npm | Description |
|---|---|---|
@kntnt/engagement-metrics |
Core measurement library (zero dependencies) | |
@kntnt/engagement-metrics-matomo |
Matomo Analytics add-on | |
@kntnt/engagement-metrics-gtag |
Google Analytics 4 (gtag.js) add-on | |
@kntnt/engagement-metrics-overlay |
Real-time visual overlay showing measurement in action |
The core package is the measurement engine. It discovers content elements on the page, tracks their visibility, runs the reading model described above, and computes engagement metrics after each tick. It has zero runtime dependencies — no frameworks, no libraries, just browser APIs.
The core also provides the API that add-on packages build on. Add-ons implement a MetricsListener interface with a single update(metrics) method and register themselves with the measurer via addListener(). The measurer then calls every registered listener on each tick with a fresh metrics snapshot. Add-ons that need per-element data (like the overlay) can also call getElements() to access individual element states.
The Matomo add-on translates engagement metrics into Matomo tracking events. It registers as a listener on the core measurer and, on each tick, checks whether any reporting threshold has been crossed. When a threshold is reached, it sends a single event via Matomo's standard _paq.push() API.
By default, reading events fire at 10%, 20%, 30%, 40%, 50%, 60%, 70%, 80%, 90%, and 100%. Scanning events fire at 25%, 50%, 75%, and 100%. These thresholds are configurable — the defaults are intentionally granular because a self-hosted Matomo instance has no event quotas or sampling limits. Each threshold fires exactly once per page visit. The add-on also supports Matomo custom dimensions for reading ratio, scanning ratio, and reading time.
The GA4 add-on works the same way as the Matomo add-on, but sends events via the gtag() function that Google Analytics 4 uses. Reading events are sent as engagement_reading and scanning events as engagement_scanning (both names are configurable).
By default, reading events fire at 10%, 25%, 50%, 75%, 90%, and 100%. Scanning events fire at 25%, 50%, 75%, and 100%. These thresholds are configurable — the defaults are sparser than Matomo's because GA4's free tier applies sampling at high event volumes, enforces daily event quotas, and limits the number of custom dimensions per property. Each event includes the threshold percentage, elapsed reading time, and current ratio as event parameters.
The overlay add-on shows measurement happening in real time, directly on the page. It colour-codes each tracked element by reading state and displays a live metrics panel — useful for understanding what the library measures and for experimenting with different reading speed settings.
What you see:
- Blue outline — element not yet scrolled into view
- Gold outline with yellow gradient — element currently being read (gradient shows progress)
- Green outline and background — element fully read
- Red outline — element previously seen but no longer visible (paused)
The HUD panel includes controls for tuning measurement parameters in real time: reading speed (50–5,000 characters per minute), scroll cooldown (0–2,000 ms), and scroll speed threshold (10–500 px/s). Sliders step in fixed increments; numeric inputs accept any value within the range. Toggle the overlay with Ctrl+Shift+D.
All options are optional. The defaults work well for most sites.
| Option | Default | Description |
|---|---|---|
selector |
'p' |
CSS selector for content elements |
exclude |
'' |
CSS selector for elements to exclude |
readingSpeed |
1380 |
Average reading speed in characters per minute |
tickInterval |
200 |
Milliseconds between measurement ticks |
observerThresholds |
[0, 0.1, 0.2, …, 0.9, 1.0] |
Visibility ratios that trigger observer callbacks |
scrollSpeedThreshold |
200 |
Minimum scroll speed (px/sec) to count as scrolling |
scrollCooldown |
50 |
Milliseconds after last scroll before reading resumes |
Determines which elements the library measures. The default 'p' targets all paragraph elements on the page. You can use any valid CSS selector — for instance, 'article :is(h1, h2, h3, p, ul, ol)' measures headings, paragraphs, and lists inside an <article> element.
Filters out elements you do not want measured. An element is excluded if it matches this selector itself or has an ancestor that matches it. For example, '.sidebar, .footer, .table-of-contents' excludes any content inside those areas, even if it would otherwise match the selector.
When left empty (the default), no elements are excluded.
The average reading speed in characters per minute. The library uses this to calculate how long a reader needs for each element: an element with 1 380 characters gets a 60-second timer at the default speed.
Research shows that readers of text written with the Latin alphabet typically spend between 42 and 45 milliseconds per character, regardless of language and text difficulty. That translates to roughly 1 330–1 430 characters per minute. The default of 1 380 characters per minute is the midpoint of that range.
You can change the reading speed at runtime via measurer.setReadingSpeed(), which recalibrates all timers while preserving current progress. The scroll speed threshold and scroll cooldown are also adjustable at runtime via measurer.setScrollSpeedThreshold() and measurer.setScrollCooldown().
How often (in milliseconds) the library runs its measurement loop. The default of 200 ms means five ticks per second. A lower value makes measurements more responsive but uses slightly more CPU. In practice, 200 ms is fine for virtually all use cases.
The visibility ratios at which the browser's IntersectionObserver reports changes. The default [0, 0.1, 0.2, …, 0.9, 1.0] means the library is notified at every 10% visibility step. More thresholds give finer-grained tracking but generate slightly more callbacks. This setting rarely needs changing.
The minimum scroll speed (in pixels per second) for the library to consider the visitor actively scrolling. While the scroll speed exceeds this threshold, reading timers are paused — the visitor is scanning, not reading. The default of 200 px/sec is a good balance for most content.
How long (in milliseconds) the library waits after the last scroll event before resuming reading timers. The default of 50 ms means the visitor must stop scrolling for 50 milliseconds before reading is counted again. This prevents brief pauses during a fast scroll from being mistaken for reading.
The library computes these metrics after each measurement tick. They are passed to all registered add-ons and are also available on demand via measurer.getMetrics().
| Metric | Type | Description |
|---|---|---|
readingTime |
seconds | Estimated time spent reading so far |
contentTime |
seconds | Total estimated reading time for all content |
readingLength |
characters | Estimated number of characters read |
contentLength |
characters | Total characters across all content elements |
scanningDepth |
pixels | Deepest scroll position reached |
contentDepth |
pixels | Total scrollable content height |
readingRatio |
0–1 | Fraction of content actually read |
scanningRatio |
0–1 | Fraction of content scrolled through |
The estimated number of seconds the visitor has spent actually reading. This is the sum of elapsed timer time across all tracked elements. A visitor who has read three paragraphs with timers of 20, 15, and 10 seconds has a reading time of 45 seconds.
Note that this is an estimate based on character count and configured reading speed — it reflects how long reading the consumed content should take, not a direct measurement of time spent on the page.
The total estimated time (in seconds) it would take to read all measured content from start to finish. This is the sum of all element timer durations. It serves as the upper bound for reading time.
The estimated number of characters the visitor has read. Each element contributes its character count multiplied by its reading progress (0 to 1). An element with 500 characters at 60% progress contributes 300 characters.
The total number of characters across all measured elements. This is a fixed value determined at initialisation. Together with reading length, it gives you the raw numbers behind the reading ratio.
The deepest scroll position (in pixels from the top of the page) the visitor has reached. This value only increases — scrolling back up does not reduce it. It represents how far the visitor has explored the page, regardless of whether they read the content.
The total scrollable height of the page in pixels (the document height minus the viewport height). Together with scanning depth, it gives you the raw numbers behind the scanning ratio.
The fraction of content estimated to have been read, expressed as a number between 0 and 1. It equals readingLength / contentLength. A reading ratio of 0.75 means roughly 75% of the text has been read.
This is typically the most useful metric for evaluating content engagement. A high reading ratio means the visitor consumed most of the content; a low ratio suggests they left early or skimmed.
How far down the page the visitor has scrolled, expressed as a number between 0 and 1. It equals scanningDepth / contentDepth, capped at 1. A scanning ratio of 1 means the visitor reached the bottom of the page.
Comparing scanning ratio with reading ratio reveals visitor behaviour: a high scanning ratio with a low reading ratio indicates fast scrolling without reading; similar values for both suggest steady, engaged reading.
The add-ons send events automatically once installed. This section explains what arrives in your analytics platform and where to find it.
No configuration is needed. Events appear in your Matomo dashboard as soon as visitors start triggering thresholds.
The add-on sends standard Matomo tracking events with this structure:
| Field | Reading events | Scanning events |
|---|---|---|
| Category | Engagement |
Engagement |
| Action | Reading |
Scanning |
| Name | Threshold reached, e.g. 50% |
Threshold reached, e.g. 75% |
| Value | Elapsed reading time (seconds) | Scanning depth (pixels) |
To find the data, go to Behaviour > Events in the Matomo dashboard and filter by the category Engagement. The Action column separates reading events from scanning events, and the Name column shows which threshold was crossed.
From there you can build segments (for example, visitors whose reading ratio reached at least 50%) or create custom reports that combine engagement data with other dimensions such as traffic source or landing page.
The default thresholds — every ten percentage points for reading and every 25 for scanning — are intentionally granular. If you want even finer steps (for example every five percentage points), you can configure them freely. Matomo has no event quotas or sampling limits, so denser thresholds have no technical cost beyond a marginally larger database.
The threshold events tell you that a visitor reached "at least 40%" but not whether the actual value was 41% or 49%. For most distribution analyses this is sufficient. If you need exact values — for example to compute average reading ratio per page or to segment visitors by precise engagement level — you can set up Matomo custom dimensions.
The add-on can send three custom dimension values: reading ratio, scanning ratio, and reading time. Each is updated whenever a threshold event fires, so the final value stored in Matomo reflects the visitor's engagement at the last threshold they crossed.
To set this up:
- Go to Administration > Custom Dimensions in Matomo (if you do not see this menu item, activate the "Custom Dimensions" plugin under Administration > Plugins).
- Create three dimensions of type Action scope — for example "Reading ratio", "Scanning ratio", and "Reading time".
- Note the numeric ID that Matomo assigns to each dimension.
- Pass the IDs in the add-on configuration:
registerMatomo(measurer, {
readingRatioDimension: 1, // replace with your actual ID
scanningRatioDimension: 2,
readingTimeDimension: 3,
})Once configured, the exact values appear alongside other dimensions in Matomo's reporting and segmentation tools.
Events arrive in GA4 immediately, but you need one extra step to make the event parameters available in reports.
The add-on sends two custom event types:
| Event name | Parameters |
|---|---|
engagement_reading |
percentage, reading_time, reading_ratio |
engagement_scanning |
percentage, scanning_depth, scanning_ratio |
GA4 collects the events automatically, but by default it does not include custom parameters in standard reports. To unlock them, register each parameter as a custom dimension:
- In your GA4 property, go to Admin > Custom definitions > Custom dimensions.
- Click Create custom dimension.
- Set the scope to Event and enter the parameter name exactly as shown above (e.g.
percentage,reading_time). - Repeat for each parameter you want to use in reports.
Once the dimensions are registered, you can find the data under Reports > Engagement > Events, filtered by event name. For more flexible analysis, use Explore to build free-form reports that combine engagement parameters with other dimensions such as page path, source/medium, or device category.
The default reading thresholds for GA4 (10%, 25%, 50%, 75%, 90%, 100%) are sparser than the Matomo defaults because GA4's free tier applies sampling at high event volumes, enforces daily event quotas, and limits the number of custom dimensions per property. If your site has moderate traffic (under 25 million events per month), you can safely add denser thresholds — for example the same ten-percentage-point steps that the Matomo add-on uses. If you start seeing "(other)" in your reports, you have hit a cardinality limit and should reduce the number of unique values.
Three GA4 features can add value beyond basic event reporting:
Key events. Mark one of the engagement events as a key event (formerly called a "conversion"). For example, mark engagement_reading with the condition that percentage is 50 or higher. Every visitor who reads at least half the content then counts as a conversion, letting you see which traffic sources and campaigns drive engaged readers — not just clicks.
Audiences. Under Admin > Audiences, create an audience such as "visitors who triggered engagement_reading with percentage ≥ 75". This gives you a segment of deep readers that you can use for remarketing or comparative analysis.
BigQuery export. If your GA4 property is linked to BigQuery, all event parameters land in the raw data automatically, free from the reporting interface's cardinality and sampling limits. You can run SQL queries against exact values, compute averages, and build custom visualisations without any additional configuration in the add-on.
All packages are published on npm under the @kntnt scope. You need the core package plus at least one add-on.
If your project uses a bundler (Vite, webpack, Parcel, or similar), install the packages as dependencies:
npm install @kntnt/engagement-metrics
npm install @kntnt/engagement-metrics-gtag # for Google Analytics 4
npm install @kntnt/engagement-metrics-matomo # for Matomo
npm install @kntnt/engagement-metrics-overlay # for the visual overlayThen import and use them in your code:
import { createMeasurer } from '@kntnt/engagement-metrics'
import { registerGtag } from '@kntnt/engagement-metrics-gtag'
const measurer = createMeasurer({
selector: 'p', // measure all <p> elements
readingSpeed: 1380, // characters per minute
})
registerGtag(measurer, {
readingThresholds: [10, 25, 50, 75, 100],
})
measurer.start()Each package also ships a pre-built minified IIFE file for direct <script> tag inclusion. You can use unpkg or jsDelivr to load them from a CDN, or download and self-host:
<!-- From unpkg -->
<script src="https://unpkg.com/@kntnt/engagement-metrics/dist/kntnt-engagement-metrics.min.js"></script>
<script src="https://unpkg.com/@kntnt/engagement-metrics-gtag/dist/kntnt-engagement-metrics-gtag.min.js"></script>
<!-- Or from jsDelivr -->
<script src="https://cdn.jsdelivr.net/npm/@kntnt/engagement-metrics/dist/kntnt-engagement-metrics.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@kntnt/engagement-metrics-gtag/dist/kntnt-engagement-metrics-gtag.min.js"></script>Then start measurement:
<script>
const measurer = KntntEngagementMetrics.start({
selector: 'article p',
readingSpeed: 1380,
})
KntntEngagementMetrics.measurer = measurer
KntntEngagementMetrics.gtag.register()
</script>To self-host instead, build the project and copy the files from each package's dist/ directory:
git clone https://github.com/Kntnt/kntnt-engagement-metrics.git
cd kntnt-engagement-metrics
bun install && bun run buildThe built IIFE files are:
packages/core/dist/kntnt-engagement-metrics.min.jspackages/matomo/dist/kntnt-engagement-metrics-matomo.min.jspackages/gtag/dist/kntnt-engagement-metrics-gtag.min.jspackages/overlay/dist/kntnt-engagement-metrics-overlay.min.js
The project uses Bun for package management, building, testing, and script running.
You need Bun installed on your machine:
- Bun (latest version)
Clone the repository and install dependencies:
git clone https://github.com/Kntnt/kntnt-engagement-metrics.git
cd kntnt-engagement-metrics
bun installThe most common commands during development:
bun run build # Build all packages
bun test # Run tests
bun run test:e2e # Run end-to-end tests (Playwright)
bun run lint # Lint with Biome
bun run typecheck # Type-check with TypeScriptContributions are welcome. Before submitting a pull request, please read the coding conventions, testing strategy, and CI and hooks setup.
MIT — Copyright (c) 2026 Kntnt Sweden AB