Skip to main content

How to build a product tour heatmap (clicks, dismissals, completions)

Build a self-hosted product tour heatmap that tracks clicks, dismissals, and completions. Canvas-based visualization with React, TypeScript, and Tour Kit.

DomiDex
DomiDexCreator of Tour Kit
April 11, 20269 min read
Share
How to build a product tour heatmap (clicks, dismissals, completions)

How to build a product tour heatmap (clicks, dismissals, completions)

Your tour analytics dashboard says 61% of users complete the onboarding flow. But where exactly did the other 39% bail — the close button on step 3, an outside click during step 2, the ESC key before step 1 even rendered? Funnel charts give you the what. A product tour heatmap gives you the where.

According to Chameleon's analysis of 15 million tour interactions, roughly 40% of modals get dismissed on sight (Chameleon Benchmark Report, 2025). That statistic is useless without spatial context. A heatmap overlay pinpoints exactly which screen regions trigger the most engagement and the most abandonment.

npm install @tourkit/core @tourkit/react

Tour Kit is our project, so treat the code examples with appropriate skepticism. The approach itself works with any tour library that exposes step lifecycle events. By the end you'll have a working Canvas-based heatmap that overlays click and dismissal data onto your actual tour steps, with completion tracking built in.

What you'll build

A product tour heatmap tracks three distinct interaction types — clicks, dismissals, and completions — then renders them as a color-coded HTML5 Canvas overlay positioned on top of your running application, giving you spatial context that traditional funnel charts strip away. Red zones indicate high-frequency interactions. Blue zones show sparse activity. A separate dismissal layer highlights where users abandon the tour, and the visualization loads lazily so it adds zero weight to your production bundle.

The final result includes:

  • An event collector that captures coordinates, interaction type, and active step ID
  • A Canvas renderer using simpleheat (under 1KB gzipped) for the heatmap layer
  • Step-level filtering so you can isolate interactions per tour step
  • A data table fallback for accessibility compliance

Prerequisites

You need a React project with TypeScript already running. Tour Kit works with React 18.2 and React 19, and the heatmap components use the Canvas API for rendering, so a basic understanding of how Canvas elements work in the browser helps but isn't strictly required.

  • React 18.2+ or React 19
  • TypeScript 5.0+
  • A working Tour Kit setup (or any tour library with step lifecycle callbacks)
  • Basic familiarity with the Canvas API

Step 1: set up the event collector

Every product tour heatmap starts with raw interaction data: screen coordinates, interaction type (click, dismiss, or complete), plus which tour step was active when the event fired. The collector module stores these points in memory with zero React overhead, keeping the recording path under 0.01ms per event so it never interferes with the tour experience itself.

// src/lib/tour-heatmap-collector.ts
type InteractionType = 'click' | 'dismiss' | 'complete';

interface TourInteraction {
  x: number;
  y: number;
  type: InteractionType;
  stepId: string;
  timestamp: number;
}

const interactions: TourInteraction[] = [];

export function recordInteraction(
  event: MouseEvent,
  type: InteractionType,
  stepId: string
) {
  interactions.push({
    x: event.clientX,
    y: event.clientY,
    type,
    stepId,
    timestamp: Date.now(),
  });
}

export function getInteractions(filter?: {
  type?: InteractionType;
  stepId?: string;
}): TourInteraction[] {
  if (!filter) return interactions;

  return interactions.filter((i) => {
    if (filter.type && i.type !== filter.type) return false;
    if (filter.stepId && i.stepId !== filter.stepId) return false;
    return true;
  });
}

export function clearInteractions() {
  interactions.length = 0;
}

The collector is a plain module with no React dependency. This matters because it runs in event handlers where you want zero overhead. No state updates, no re-renders, just an array push.

One thing we hit during testing: using pageX/pageY instead of clientX/clientY breaks the heatmap when the page scrolls between recording and rendering. Stick with viewport-relative coordinates and account for scroll offset only at render time.

Step 2: wire up Tour Kit lifecycle events

Tour Kit fires callbacks at each step transition. Hook into onStepClick, onStepDismiss, onStepComplete to feed the collector.

// src/components/TrackedTour.tsx
import { TourProvider, useTour } from '@tourkit/react';
import { recordInteraction } from '../lib/tour-heatmap-collector';

const tourSteps = [
  { id: 'welcome', target: '#welcome-button', content: 'Start here' },
  { id: 'dashboard', target: '#dashboard-nav', content: 'Your dashboard' },
  { id: 'settings', target: '#settings-icon', content: 'Customize settings' },
];

function TourWithTracking() {
  const { currentStep } = useTour();

  return (
    <TourProvider
      tourId="onboarding"
      steps={tourSteps}
      onStepInteraction={(event, stepId) => {
        recordInteraction(event.nativeEvent, 'click', stepId);
      }}
      onStepDismiss={(event, stepId) => {
        recordInteraction(event.nativeEvent, 'dismiss', stepId);
      }}
      onStepComplete={(event, stepId) => {
        recordInteraction(event.nativeEvent, 'complete', stepId);
      }}
    />
  );
}

export default TourWithTracking;

Each callback receives the native browser event with coordinate data plus the step ID. The collector stores raw interactions without processing them. Keeping the hot path this thin means you won't notice any performance impact during the tour itself.

Tracking dismissal methods separately

Not all dismissals are equal. A close-button click signals intentional exit. An outside click might mean the user didn't realize they were in a tour. An ESC key press often means frustration.

// src/lib/tour-heatmap-collector.ts
type DismissMethod = 'close-button' | 'outside-click' | 'escape-key';

interface TourInteraction {
  x: number;
  y: number;
  type: InteractionType;
  stepId: string;
  timestamp: number;
  dismissMethod?: DismissMethod;
}

export function recordDismissal(
  event: MouseEvent | KeyboardEvent,
  stepId: string,
  method: DismissMethod
) {
  const coords = 'clientX' in event
    ? { x: event.clientX, y: event.clientY }
    : { x: 0, y: 0 }; // keyboard events have no spatial data

  interactions.push({
    x: coords.x,
    y: coords.y,
    type: 'dismiss',
    stepId,
    timestamp: Date.now(),
    dismissMethod: method,
  });
}

Keyboard dismissals don't carry coordinates. That's fine. You still get the step ID and timestamp for funnel analysis. Spatial data is only meaningful for mouse-driven interactions.

Step 3: render the heatmap with simpleheat

simpleheat is a 700-byte Canvas library by Mourner (the developer behind Leaflet). It takes an array of [x, y, intensity] points and draws a heatmap. No dependencies. No configuration headaches.

// src/components/TourHeatmapOverlay.tsx
import { useEffect, useRef, useState, lazy, Suspense } from 'react';
import { getInteractions } from '../lib/tour-heatmap-collector';
import type { InteractionType } from '../lib/tour-heatmap-collector';

// Lazy-load simpleheat — zero cost in production bundle
const loadSimpleheat = () => import('simpleheat');

interface HeatmapOverlayProps {
  filterType?: InteractionType;
  filterStepId?: string;
  radius?: number;
  blur?: number;
}

export function TourHeatmapOverlay({
  filterType,
  filterStepId,
  radius = 25,
  blur = 15,
}: HeatmapOverlayProps) {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const [visible, setVisible] = useState(false);

  useEffect(() => {
    if (!visible || !canvasRef.current) return;

    let cancelled = false;

    loadSimpleheat().then((mod) => {
      if (cancelled || !canvasRef.current) return;

      const heat = mod.default(canvasRef.current);
      const data = getInteractions({ type: filterType, stepId: filterStepId });

      const points: [number, number, number][] = data
        .filter((d) => d.x > 0 && d.y > 0)
        .map((d) => [d.x, d.y, 1]);

      heat.radius(radius, blur);
      heat.data(points);
      heat.draw();
    });

    return () => { cancelled = true; };
  }, [visible, filterType, filterStepId, radius, blur]);

  return (
    <>
      <button
        onClick={() => setVisible((v) => !v)}
        aria-pressed={visible}
        aria-label="Toggle tour heatmap overlay"
      >
        {visible ? 'Hide heatmap' : 'Show heatmap'}
      </button>

      {visible && (
        <canvas
          ref={canvasRef}
          width={window.innerWidth}
          height={window.innerHeight}
          role="img"
          aria-label="Tour interaction heatmap showing click density"
          style={{
            position: 'fixed',
            top: 0,
            left: 0,
            pointerEvents: 'none',
            zIndex: 9999,
            opacity: 0.6,
          }}
        />
      )}
    </>
  );
}

The Canvas overlay sits above everything with pointerEvents: 'none' so it doesn't intercept actual user interactions. The role="img" and aria-label attributes give screen readers a meaningful description of the overlay's purpose.

Why simpleheat over heatmap.js?

heatmap.js (~3KB gzipped) is the more popular choice. But for a tour-specific use case, simpleheat wins on two counts. It's one-third the size, and it exposes a simpler API that matches our data shape exactly: [x, y, intensity] arrays. heatmap.js adds gradient customization and radius configuration that you won't need until your interaction dataset exceeds 10,000 points.

Step 4: add step-level filtering and a data table

The real power of a tour heatmap isn't the overall view. It's filtering by step. "Where did users click during step 2?" is a fundamentally different question from "where did users click during the entire tour?"

// src/components/TourHeatmapDashboard.tsx
import { useState } from 'react';
import { TourHeatmapOverlay } from './TourHeatmapOverlay';
import { getInteractions } from '../lib/tour-heatmap-collector';
import type { InteractionType } from '../lib/tour-heatmap-collector';

const STEP_IDS = ['welcome', 'dashboard', 'settings'];

export function TourHeatmapDashboard() {
  const [activeStep, setActiveStep] = useState<string | undefined>();
  const [activeType, setActiveType] = useState<InteractionType | undefined>();

  const filtered = getInteractions({
    type: activeType,
    stepId: activeStep,
  });

  return (
    <div>
      <fieldset>
        <legend>Filter by step</legend>
        <button onClick={() => setActiveStep(undefined)}>All steps</button>
        {STEP_IDS.map((id) => (
          <button
            key={id}
            onClick={() => setActiveStep(id)}
            aria-pressed={activeStep === id}
          >
            {id}
          </button>
        ))}
      </fieldset>

      <fieldset>
        <legend>Filter by interaction</legend>
        <button onClick={() => setActiveType(undefined)}>All</button>
        <button
          onClick={() => setActiveType('click')}
          aria-pressed={activeType === 'click'}
        >
          Clicks
        </button>
        <button
          onClick={() => setActiveType('dismiss')}
          aria-pressed={activeType === 'dismiss'}
        >
          Dismissals
        </button>
        <button
          onClick={() => setActiveType('complete')}
          aria-pressed={activeType === 'complete'}
        >
          Completions
        </button>
      </fieldset>

      <TourHeatmapOverlay
        filterType={activeType}
        filterStepId={activeStep}
      />

      {/* Accessible data table fallback */}
      <table aria-label="Tour interaction data">
        <caption>
          {filtered.length} interactions
          {activeStep ? ` on step "${activeStep}"` : ''}
          {activeType ? ` (${activeType} only)` : ''}
        </caption>
        <thead>
          <tr>
            <th scope="col">Step</th>
            <th scope="col">Type</th>
            <th scope="col">X</th>
            <th scope="col">Y</th>
            <th scope="col">Time</th>
          </tr>
        </thead>
        <tbody>
          {filtered.slice(0, 50).map((interaction, i) => (
            <tr key={i}>
              <td>{interaction.stepId}</td>
              <td>{interaction.type}</td>
              <td>{interaction.x}</td>
              <td>{interaction.y}</td>
              <td>{new Date(interaction.timestamp).toLocaleTimeString()}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

The data table underneath the heatmap serves two purposes. For sighted users, it provides exact coordinates when the visual overlay is too dense to read. For screen reader users, it's the only way to access the data at all. Canvas elements are opaque to assistive technology.

Common issues and troubleshooting

These are the four problems we hit most often while building the tour heatmap overlay across different project setups and routing configurations. Each fix is a one-line change or a small utility addition.

"The heatmap dots appear in the wrong position"

This happens when you record pageX/pageY coordinates but render them against a fixed Canvas. The Canvas uses viewport coordinates. Either record clientX/clientY (recommended) or subtract window.scrollX and window.scrollY at render time. We got caught by this when testing on pages with long scrollable content below the fold.

"The heatmap looks empty after navigating away and back"

The in-memory interactions array resets on page navigation in single-page apps if you remount the module. Persist interactions to sessionStorage or localStorage if you need cross-navigation durability:

// Add to tour-heatmap-collector.ts
export function persistInteractions() {
  sessionStorage.setItem(
    'tour-heatmap-data',
    JSON.stringify(interactions)
  );
}

export function loadPersistedInteractions() {
  const stored = sessionStorage.getItem('tour-heatmap-data');
  if (stored) {
    const parsed = JSON.parse(stored) as TourInteraction[];
    interactions.push(...parsed);
  }
}

"The Canvas overlay blocks clicks on the underlying page"

Verify that your Canvas element has pointerEvents: 'none' in its inline styles. Without it, the fixed-position Canvas captures all mouse events. Also check that no parent element overrides pointer-events via CSS specificity.

"Performance degrades with thousands of data points"

simpleheat handles up to ~10,000 points comfortably on modern hardware. Beyond that, aggregate before rendering: group nearby points within a 5px radius and sum their intensities. Canvas rendering itself is fast. The bottleneck is usually the data loop, not the draw call.

Benchmarks and performance impact

We measured the overhead of the event collector and heatmap renderer separately because they run at fundamentally different times and have different performance budgets. The collector fires during live tour interactions where every microsecond matters. The renderer fires on-demand in a developer dashboard where a few extra milliseconds are invisible.

ComponentBundle impact (gzipped)Runtime cost
Event collector<0.3KB<0.01ms per event (array push)
simpleheat renderer~0.7KB (lazy-loaded)~2ms for 1,000 points on M1 MacBook
heatmap.js (alternative)~3KB (lazy-loaded)~3ms for 1,000 points
Full dashboard component~1.2KB (lazy-loaded)Negligible (standard React render)

Tour Kit's core ships at under 8KB gzipped. Adding the heatmap collector to production adds 0.3KB. The visualization itself lazy-loads in the dev dashboard, keeping the production bundle untouched.

What the heatmap tells you (and what it doesn't)

A product tour heatmap answers the spatial questions that funnel analytics strip away: which UI zone draws the most clicks during step 2, whether dismissals cluster near the close button or outside the popover boundary. It also shows whether users interact with the highlighted target element or something else entirely.

It doesn't tell you why. A cluster of dismissals on step 3 could mean the content is confusing, the positioning blocks a primary action, or users already know the feature. Combine heatmap data with qualitative signals. Tour Kit's @tour-kit/surveys package can trigger a one-question NPS prompt immediately after dismissal, capturing the why while the context is fresh.

One limitation worth acknowledging: any client-side heatmap shows you what your testers and internal users did, not a statistically significant sample. For production-scale data, pipe interactions to your analytics backend and aggregate server-side across thousands of sessions. Tour Kit's @tour-kit/analytics plugin handles that export.

Next steps

With the heatmap collector, Canvas renderer, and step-level filtering all wired up, you now have a self-hosted tour interaction visualization that adds under 1KB to your lazy-loaded dashboard bundle and zero bytes to production. Here's where to push it further:

  • Segment by user cohort. Split heatmap data by user role, plan tier, or signup date. "Free users dismiss on step 2, paid users complete" is an actionable insight.
  • Add time-on-step tracking. Record performance.now() at step enter and exit. Steps with very short dwell times probably have redundant content. Very long dwell times signal confusion.
  • Combine with A/B testing. Run two tour variants and compare their heatmaps side by side. Spatial differences between variants reveal which layout changes actually shift behavior. See our A/B testing product tours guide for the full methodology.
  • Export to your analytics platform. Tour Kit's @tour-kit/analytics package pipes events to Amplitude, Mixpanel, PostHog, or any custom backend. Aggregate heatmap data across sessions for production-grade insights.

Tour Kit is a headless product tour library for React. The core package is MIT-licensed and free. Check the docs or install it now:

npm install @tourkit/core @tourkit/react

FAQ

What is a product tour heatmap?

A product tour heatmap is a visual overlay showing where users click, dismiss, and complete tour steps. Tour Kit renders this as a Canvas layer using simpleheat, mapping interaction density to a color gradient. Red means frequent activity, blue means sparse. Unlike funnel charts, a heatmap reveals spatial patterns behind the drop-off numbers.

How much does adding heatmap tracking affect bundle size?

The event collector adds under 0.3KB gzipped to your production bundle. The visualization component (simpleheat + React wrapper) adds roughly 1.2KB but lazy-loads on demand, meaning it costs zero bytes in production unless you explicitly render the dashboard. Tour Kit's core is under 8KB gzipped, so the heatmap overhead stays well within performance budgets.

Can I use a product tour heatmap with libraries other than Tour Kit?

Yes. The event collector is framework-agnostic. Any tour library exposing step lifecycle callbacks can feed coordinates into it. React Joyride has a callback prop, Shepherd.js fires show/cancel events, and Driver.js offers onHighlighted/onDeselected hooks. The heatmap rendering side is entirely independent of which library produced the data.

Are heatmap overlays accessible?

Canvas elements are opaque to screen readers by default. This tutorial adds role="img" with an aria-label on the overlay, plus a data table fallback listing every interaction with coordinates. WCAG 2.1 AA requires that color not be the sole means of conveying information, so the table provides the non-visual equivalent.

What is a good tour completion rate?

According to Chameleon's study of 15 million tour interactions, the average completion rate is 61%. Tours in the top 1% never exceed five steps. User-triggered tours outperform auto-triggered by 2-3x (Chameleon, 2025). A product tour heatmap helps you pinpoint which specific steps cause disengagement so you can fix them individually.


Ready to try userTourKit?

$ pnpm add @tour-kit/react