🖱 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.

🔭 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 #
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 maximize 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 the user scrolls up just another
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
#
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 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'}
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
#
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 something like:
const options = {rootMargin: '0px',threshold: 0.9}
Here, we need to have the observed element 90% visible to trigger. Remember, 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.
🗳 Poll #
🧱 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 trigger a view once the user scrolls the heading out of view. So let’s say we have a structure something like this:
<main><picture>...<img ...></picture><h1>Article Title</h1><p>First sentence</p>}
So once the user scrolls past the h1
heading, we
trigger a view.

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. We will wrap the element we want to observe (the heading) in a new
IntersectionObserver component. This will contain an @render
element, for the observed element:
1 <script>2 import { onMount, onDestroy } from 'svelte';3 import { browser } from '$app/environment';45 let { children } = $props();67 function handleView() {8 alert('Intersection Observer view event triggered');9 }1011 let container;12 let observer;1314 onMount(() => {15 if (browser) {16 const handleIntersect = (entries, observer) => {17 entries.forEach((entry) => {18 if (entry.isIntersecting) {19 observer.unobserve(entry.target);20 handleView();21 }22 });23 };24 const options = { threshold: 1, rootMargin: '100% 0% -100%' };25 observer = new IntersectionObserver(handleIntersect, options);26 observer.observe(container);27 }28 });2930 onDestroy(() => {31 if (observer) {32 observer.disconnect();33 }34 });35 </script>3637 <div bind:this={container}>38 {@render children?.()}39 </div>
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 5
– 7
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 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 22
. Here we define the options. We set a threshold of 1, meaning we trigger
the intersection when we go from the heading 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 heading 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 23
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 line just tells the Intersection
Observer which element to observe. We have bound this element to the container
variable, using bind:this
in line 35
. This saves us placing an id
on the element
and attaching it using a query selector.
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 17
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 heading 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 18
we call our handleView
function. In a real-world
application, this would add a new view to our database.
💯 Testing it Out #
Please enable JavaScript to watch the video 📼
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 #
-
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 dev
-
Create a new file
src/lib/components/IntersectionObserver.svelte
and paste in the code block above. -
Edit the
src/lib/components/BlogPost.svelte
component to import the IntersectionObserver component and add it to the DOM:src/lib/components/BlogPost.sveltesvelte1 <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.sveltesvelte72 <BannerImage {imageData} />73 <IntersectionObserver>74 <h1 class="heading">{title}</h1>75 </IntersectionObserver> - Navigate to a blog post on the dev site and scroll past a picture, an alert should appear. You can now customize 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.