Tracking Page Views in SvelteKit: Intersection Observer

Rodney Lab - Sep 27 '21 - - Dev Community

🖱 Tracking Scroll Events in SvelteKit

In this post we look at how to make tracking page views in SvelteKit easy. You might have a blog and want to add a view counter to improve user experience. You might also want to keep track of which articles or pages on a website get read right to the end. This will give stakeholders a better impression of what content works well. Although we focus on a page view example, the techniques we look at here can be used to track a wider set of scroll events. As an example, you may want to know when an iframe is about to come into the visible viewport to trigger a lazy load. Finally, you might want to change a header component based on which section of the page the user is currently viewing. All of these problems can be solved using the Intersection Observer API.

In our example we will consider the page viewed once the user has scrolled the post banner image completely out of view.

Tracking Page Views in SvelteKit: Intersection Observer: Screenshot show top of blog post page with a navigation bar up top and a large postbanner image just below

🔭 Intersection Observer API

Although the Intersection Observer API was introduced to make tracking scroll event simpler, it can be a little daunting, so we will try to break it down here. We will end with some SvelteKit code which you can use as a starting point for your own projects.

iframe Example

Essentially we use the Intersection Observer API, to tell us whether an element is in view or not. This element would be the iframe in the case we were lazy loading an iframe and wanted to know when it was in view. As well as the element we are tracking we have a reference frame, the root element.

By default, the root element is the viewport. So we track whether the observed element (the iframe in our example) is inside the root element. In the case of a lazy loading iframe, if we wanted to maximise user experience, we would start lazy loading the iframe before it came into the root element (the viewport in our case). To do this we might say trigger the lazy load when the iframe is within 100 px of the bottom of the root element, so it is not yet visible, but will be visible as soon a the user scrolls up just anothe 100 pixels. In this case the rootMargin parameter is helpful.

Ad Example

With the iframe example, we want to trigger as soon as the first pixel of the iframe enters our (extended) root element. If we were placing an ad on our site and want to record the number of views of the ad, we might consider the add viewed once say 90% of it is visible in the viewport. Here we would not need to extend the root element as with the iframe. But we would want to trigger once 90% was in view, rather than the very first pixel and can do this by using the threshold parameter.

One thing to note on the Intersection Observer is that it is triggered in either direction. Meaning, by default, with the iframe example. With the iframe initially out of view, the user scrolls down and the event is triggered (iframe switches from being outside the root element to being inside). If the user now scrolls up again, a fresh event is triggered (iframe switches from being inside the reference element to outside).

Equally, when the iframe is in view and the user scrolls right down to the bottom of the page, so the iframe is no longer visible, another event is triggered (iframe switches from being inside the root element to being outside). Taking this into account, depending on the use case, you probably want to disable the observer once the first event is triggered. In the iframe example, you only need to lazy load it once! In the ad example, the advertiser might accuse you of fraud if you count a view (and bill them for it) when the ad enters the viewport and another when it leaves!

rootMargin

Root margin can be used to grow or shrink the root element. Think of it like a CSS margin when you set parameters. That said, you can only specify in units of pixels or a percentage (also, be sure to write 0px, rather than just 0). Why would you want to grow or shrink the root element? By default the root element is the visible viewport. If we want to the observer to trigger a lazy load of an iframe, it makes sense to trigger before the iframe enters the viewport, to give it time to load and improve user experience. Here growing the root element helps. Let's say we went for this:

const options = {
  rootMargin: '0px 0px 100px'
}
Enter fullscreen mode Exit fullscreen mode

We interpret this as we would a CSS margin, so the first 0px means apply a top margin of zero (i.e. do nothing with the top of the root element). The second 0px refers to left and right margin, again we do nothing. The 100px refers to the bottom margin. We are saying grow the root element by shifting the bottom of it out 100 pixels. This is just what we need; by growing the root element, we can trigger the observation earlier and anticipate the iframe coming into the view, getting ready a touch sooner.

Remember this parameter works like a CSS margin so a negative value will decrease the size of the root element while a positive value increases it.

threshold

The threshold option just controls how much of the observed element needs to be visible for an event to be triggered. For the iframe example, we can keep it at the default 0, meaning as soon as the first pixel enters the root element, we trigger an observation. For the ad example, we might try somehting like:

const options = {
  rootMargin: '0px',
  threshold: 0.9
}
Enter fullscreen mode Exit fullscreen mode

Here we need to have the observed element 90% visible to trigger. Rememember that triggers work both ways. So if we are scrolling the observer element into view and it goes from the top 89% being visible to the top 91% being visible, we have a trigger. If we continue scrolling, we might get to a point where only the bottom 91% is visible. If we continue scrolling, we will trigger another event once less than the bottom 90% is visible.

Hope that I explained it well enough! Let me know if there's some element I can improve on. That's enough theory for now. Let's code up an example.

🧱 Tracking Page Views in SvelteKit

Let's leave behind our iframe and ad examples and look at a page view. We have a blog and want to know how many times each post gets viewed. We could trigger a view as soon as the page loads. Though what happens if the user clicked the wrong link and immediately presses the back button? We would count a view, when the user didn't event read the first sentence.

In reality you would want to trigger a view once the user scrolls past let's say 25%, 50% or 75% of the article into view. You would choose the threshold based best-suited to your needs. We'll keep it simple here. We assume you write semantic HTML and have exactly one main element on your blog post pages. We trigger a view once the user scrolls the first child element of the main element out of view. So let's say we have a structure somthing like this:

<main>
    <picture>
        ...
        <img ...>
    </picture>
    <h1>Article Title</h1>
    <p>First sentence</p>
}
Enter fullscreen mode Exit fullscreen mode

The first child element of the main element is the picture, so once the user scrolls past that, we trigger a view.

Tracking Page Views in SvelteKit: Intersection Observer: Screenshot show top of blog post page with a navigation bar up top and a large post banner image just below

Now we know what our metric is, let's write some Svelte! We'll create a component just for the intersection observer and place it in its own file. Although the content is in a .svelte file, it will not actually render anything in our example.

<script>
  import { onMount, onDestroy } from 'svelte';
  import { browser } from '$app/env';

  function handleView() {
    alert('Intersection Observer view event triggered');
  }

  let observer;

  onMount(() => {
    if (browser) {
      const handleIntersect = (entries, observer) => {
        entries.forEach((entry) => {
          if (entry.isIntersecting) {
            observer.unobserve(entry.target);
            handleView();
          }
        });
      };
      const options = { threshold: 1, rootMargin: '100% 0% -100%' };
      observer = new IntersectionObserver(handleIntersect, options);
      const element = window.document.querySelector('main').firstElementChild;
      observer.observe(element);
    }
  });

  onDestroy(() => {
    if (observer) {
      observer.disconnect();
    }
  });
</script>
Enter fullscreen mode Exit fullscreen mode

A Closer Look at the Code

This is not as daunting as it might first look. Let's break it down and see why. First we import onMount, onDestroy and browser. You might already know browser is a SvelteKit inbuilt boolean which returns true when our code is running in the browser and false, on the server. onMount and onDestroy let us create code that only needs to be run once, as the component is created or once no longer needed.

The handleView function is lines 57 contains the code we would normally run on a view. This would involve updating the view counter in the UI and also letting the database know there was a new view.

We will create an observer variable and want to access it both in onMount and in onDestroy. For that reason we declare it without assigning a value outside of both those functions, so that in can de accessed from within them.

Intersection Observer Options

The onMount function contains the substance of our component. First let's look at line 21. Here we define the options. We set a threshold of 1, meaning we trigger the intersection when we go from the picture being less than 100% visible to being 100% visible or vice versa. This does not sound like it will do what we want it to but let's carry on anyway.

Interestingly, we are increasing the top margin by 100% (also in line 21), this makes the root element bigger. So if we have a viewport height of 812 px, our root element now starts 812 px above the top of the viewport and ends at the bottom of the view port. Next, we make no change to the left and right root margin, but decrease the bottom margin by 100%. Now the bottom margin essentially moves to the top of the view port.

What have we done here? We have shifted the entire root element so that it is off screen, standing on top of the viewport. This is actually quite convenient for our use case. Remember we want to know when our observed element scrolls off the top of the visible viewport. Now (because of our margin adjustments), when that happens, the entire element will be in our shifted rootElement. When the last pixel of the picture scrolls up out of view, 100% of the picture will be in our shifted root element. This is why we set the trigger to 1 — once 100% of the picture is in the shifted rootElement, we want to trigger an intersection.

Creating an Intersection Observer

In line 22 we create the Intersection Observer, using the options we just defined. As well as the options we pass a callback function. This is called when an intersection is observed. The next lines find the element we want to observe and tell the Intersection Observer to observe it.

Intersection Observer Callback

Finally, we have our callback function: handleIntersect. The API passes in two parameters which we will use: entries and observer. Entries is an array, in our case it will only ever have one entry. That is because we defined a single threshold. You can define threshold as an array though (let's say you want to know when 25%, 50% and 75% of the element is visible) and be able to discern which threshold was triggered in the callback.

Line 16 is quite important as it tells the observer to stop observing, once we have an intersection. We only need to count a view once the picture first scrolls out of view. If the user scrolls to the top of the page again, we don't need to count another view. Once the first view is counted, the observer has done its work and can chill!

Equally important is remembering to use our intersection event. In line 17 we call our handleView function. In a real-world application, this would add a new view to our database.

💯 Testing it Out

We can test the component by cloning the SvelteKit MDsveX starter, adding the new component and then adding the component to the rendered content in the BlogPost template. Let's do that quickly now.

How to Track Page Views in SvelteKit

  1. Clone the MDsveX blog starter and spin up a local dev server:
    git clone https://github.com/rodneylab/sveltekit-blog-mdx.git sveltekit-intersection-observercd sveltekit-intersection-observercp .env.EXAMPLE .envpnpm install # or npm installpnpm run dev
  2. Create a new file src/lib/components/IntersectionObserver.svelte and paste in the code block above.
  3. Edit the src/lib/components/BlogPost.svelte component to import the IntersectionObserver component and add it to the DOM:src/lib/components/BlogPost.sveltejsx
    1<script>2  import readingTime from 'reading-time';3  import BannerImage from '$lib/components/BannerImage.svelte';4  import IntersectionObserver from '$lib/components/IntersectionObserver.svelte';5  import SEO from '$lib/components/SEO/index.svelte';
    src/lib/components/BlogPost.sveltejsx
    72<IntersectionObserver />73<BannerImage {imageData} />74<h1 class="heading">{title}</h1>
  4. Navigate to a blog post on the dev site and scroll past a picture, an alert should appear. You can now customise the code, adding a counter to the DOM and hooking up the handleView function in the Intersection Observer component to your database.

There is a full working example on the Rodney Lab GitHub page. As well as this I have deployed a full working demo. I hope all the steps above are clear and you know have a working knowledge of the Intersection Observer API and how to use it in SvelteKit. If there is any way I could improve on this post, please drop a comment below or get in touch. Also check out the MDN docs on the Intersection Observer API. I deliberately explained it a little differently here so that you can use those docs to complement the explanation above. They have a nice animation which might bring it home, if you are not yet 100% comfortable.

🙏🏽 Feedback

Have you found the post useful? Do you have your own methods for solving this problem? Let me know your solution. Would you like to see posts on another topic instead? Get in touch with ideas for new posts. Also if you like my writing style, get in touch if I can write some posts for your company site on a consultancy basis. Read on to find ways to get in touch, further below. If you want to support posts similar to this one and can spare a few dollars, euros or pounds, please consider supporting me through Buy me a Coffee.

Finally, feel free to share the post on your social media accounts for all your followers who will find it useful. As well as leaving a comment below, you can get in touch via @askRodney on Twitter and also askRodney on Telegram. Also, see further ways to get in touch with Rodney Lab. I post regularly on SvelteKit as well as other topics. Also subscribe to the newsletter to keep up-to-date with our latest projects.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .