DEV Community

Dan Strandberg
Dan Strandberg

Posted on

Visibility detection using Svelte

I've been playing around with Svelte lately and I really enjoy it. If you haven't heard of Svelte before I highly recommend watching Rethinking reactivity from the author Rich Harris.

Svelte compiles into imperative and performant JavaScript, it uses no Virtual DOM diffing like many other frameworks. The compilation step will strip out features and styles that aren't used so it won't get into the final bundle. But the best thing about Svelte could be the expressiveness of the framework 😍.

TLDR Visibility detection

Project setup

Some of you might already be familiar with Svelte, some might not, I will try explain as much as I can so it's easy to follow along.

To create a new Svelte project:

npx degit sveltejs/template my-svelte-project

This uses the default sveltejs template to generate the project files. You can change to folder name if you like. Our new project should look something like this:

β”œβ”€β”€ README.md
β”œβ”€β”€ package.json
β”œβ”€β”€ public
β”‚Β Β  β”œβ”€β”€ favicon.png
β”‚Β Β  β”œβ”€β”€ global.css
β”‚Β Β  └── index.html
β”œβ”€β”€ rollup.config.js
└── src
    β”œβ”€β”€ App.svelte
    └── main.js

Now within the project folder we should start by installing all the dependencies.

npm install

βΈ¨       β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘βΈ©βΈ© :. extract:svelte: ...

With our dependencies installed we can start our dev-server.

npm run dev

Your application is ready~! πŸš€

- Local:      http://localhost:5000

Now we are up and running, you can visit localhost:5000 and you'll see a simple Hello World application.

What we are going to build

It's not uncommon to defer loading content until it's visible to the user, often referred to as lazy loading. To be able to lazy load we need a way to detect when elements are in screen.

Maybe we can aim to build a general purpose Visibility detector and through it see how Svelte can interact with web api's like the InterSectionObserver. Maybe we can make it reusable and flexible enough for different uses cases including but not limited to lazy loading.

Let's create a new file in the src directory called Visibility.svelte that will hold the code for our Visibility detection.

:
└── src
    β”œβ”€β”€ Visibility.svelte <-- NEW FILE
    β”œβ”€β”€ App.svelte
    └── main.js

Our Visibility component will make use of the IntersectionObserver and through it we will register an element and detect when it intersects with the viewport of our document.

It can take a threshold option and with it we can configure which parts in the intersection we are interested in.

Say we provide an array of [0, 0.5, 1] for the threshold, this would result in events when it starts to intersect (0), when 50% of our element is visible (0,5) and when the element is completely visible (1).

Alt Intersection Observer illustration

Code walkthrough

In Svelte the JavaScript is put within a <script> tag. Most of the js code will work just as expected but there's also some Svelte specific functionality which I'll try to explain.

This component doesn't have any styling, if it did it would be put within a <style> tag and be scoped to the component.

The HTML elements are put in the document just as you would in a regular index.html file.

Visbility.svelte

<script>
    import {onMount} from 'svelte';

    export let top = 0;
    export let bottom = 0;
    export let left = 0;
    export let right = 0;

    export let steps = 100;

    let element;
    let percent;
    let observer;
    let unobserve = () => {};
    let intersectionObserverSupport = false;

    function intersectPercent(entries) {
        entries.forEach(entry => {
            percent = Math.round(Math.ceil(entry.intersectionRatio * 100));
        })
    }

    function stepsToThreshold(steps) {
        return [...Array(steps).keys()].map(n => n / steps)
    }

    onMount(() => {
        intersectionObserverSupport =
                'IntersectionObserver' in window &&
                'IntersectionObserverEntry' in window &&
                'intersectionRatio' in window.IntersectionObserverEntry.prototype;

        const options = {
            rootMargin: `${top}px ${right}px ${bottom}px ${left}px`,
            threshold: stepsToThreshold(steps)
        };

        if (intersectionObserverSupport) {
            observer = new IntersectionObserver(intersectPercent, options);
            observer.observe(element);
            unobserve = () => observer.unobserve(element);
        }

        return unobserve;
    });
</script>

<div bind:this={element}>
    <slot {percent} {unobserve}/>
</div>

In Svelte whenever we want to expose properties in our component we use export let <property name> and if we assign it a value it will act as the default if it's not passed in.

The first collection of properties we expose is top, left, bottom and right. These are offset values for the intersection container, they adjust the placement of the "box" that our elements will pass through.

The next property we have is steps, this is just a number we are going to use to create thresholds, if we set it to 100 it would create [0.01, 0.02 ... 0.98, 0.99, 1]. If we have 100 steps then events will be emitted whenever the visibility changes by one percent.

Elements we want to observe needs to be in the DOM, so we import the life cycle method onMountfrom Svelte. It takes a callback that's invoked when the DOM is ready. And if we return a function from the onMount callback it gets invoked when the component is destroyed.

We return the unobserve function to ensure it is called at end of the component life cycle. Now before we continue I would like to show how this component would be used.

import Visibility from `Visibility.svelte`

<Visibility steps={100} let:percent let:unobserve}>
  {#if percent > 50}
    <h1 use:unobserve>Hello world</h1>
  {/if}
</Visibility>

In the code above we has access to percent and unobserve (I'll come back to how this works in a moment). The use:unobserve is called an action in Svelte, the action will be invoked when the if statement is true, the <h1> gets created and we unobserve the container that wraps our content.

<div bind:this={element}>
 <slot {percent} {unobserve} />
</div>

Behind the scenes this is wired up with a div using bind:this={element} directive. This allow us to get a reference to the DOM element and bind it to the variable inside the brackets.

The reason we use a wrapping div is because the InterSectionObserver needs a DOM element and we're not allowed to use directives on the slot itself.

The slot item will take on the content we pass into the <Visibility> tag. The {percent} and {unregister} is slot properties, this is why we can access them in the parent through let:percent and let:unregister.

That's all we need for flexible lazy loading. Now we have steps set to 100, it's not required though, we could use steps={2} to get [0, 0.5] and it would still work.

The example above is very simple and might be a little tricky to see what's going on. We would need to put the content off screen and maybe visualize the precent somehow.

We can use the Online REPL. Here's one example that lazy loads an image once the visibility hits 70 percent: https://svelte.dev/repl/97df8ddcd07a434890ffb38ff8051291?version=3.19.1

And here's the code, the reload function is just for convenience so we can easily try it out more than once. The whole <Visibility> block will be recreated when hitting reload.

<script>
    import Visibility from './Visibility.svelte'

    let show = true;

    function reload() {
        show = false;
        setTimeout(() => show = true, 100)
    }
</script>

<style>
    main {
        text-align: center;
    }

    h1 {
        letter-spacing: .1rem;
        margin-bottom: 100vh;
    }

    section {
        display: flex;
        align-items: center;
        justify-content: center;
        margin-bottom: 10rem;
        padding: 1rem;
        position: relative;
        box-shadow: 0 0 10px -5px black;
        height: 300px;
    }

    img {
        height: 300px;
        width: 300px;
    }

    .top-left, .top-right, .bottom-left, .bottom-right {
        position: absolute;
        background: yellow;
        padding: .5rem;
        font-size: .8rem;
        font-weight: 700;
    }

    .top-left {
        top: 0;
        left: 0;
    }

    .top-right {
        top: 0;
        right: 0;
    }

    .bottom-left {
        bottom: 0;
        left: 0;
    }

    .bottom-right {
        bottom: 0;
        right: 0;
    }
</style>

<main>
    <button on:click={reload}>Reload</button>
    <h1>Scroll down</h1>

    {#if show}
        <Visibility steps={100} let:percent let:unobserve>
            <section>
                <span class="top-left">{percent}%</span>
                <span class="top-right">{percent}%</span>
                <span class="bottom-left">{percent}%</span>
                <span class="bottom-right">{percent}%</span>


                {#if percent > 70}
                    <img alt="Robot"
                         use:unobserve
                         src="https://robohash.org/svelte-is-awesome.png">
                {/if}
            </section>
        </Visibility>
    {/if}
</main>

Let's get a little crazy

We could keep the observer running and hook into the percent values we get and use it to create some new styles: https://svelte.dev/repl/6d5c36ae0d2647298f0485b00b9dbfa9?version=3.19.1

<script>
    import Visibility from './Visibility.svelte'

    function getStyle(percent) {
        return `
            opacity: ${percent/100};
            transform: rotate(${percent * 3.6}deg) scale(${percent/100});
        `
    }
</script>

<!-- styles here, omitted for brevity -->

<main>
    <h1>Scroll down</h1>

    <Visibility steps={100} let:percent let:unobserve>
        <section style="{getStyle(percent)}">
            <span class="top-left">{percent}%</span>
            <span class="top-right">{percent}%</span>
            <span class="bottom-left">{percent}%</span>
            <span class="bottom-right">{percent}%</span>

            <figure>
                <img alt="Robot"
                     src="https://robohash.org/svelte-is-awesome.png">
            </figure>
        </section>
    </Visibility>
</main>

Here we modify the DOM with new styles, but we could also make use of svelte transitions and I encourage you to. These will get translated to css animations that runs off the main thread.

Let's see a quick example: https://svelte.dev/repl/7bc94b49825f47728444fe8b0ed943cc?version=3.19.2

<script>
    import Visibility from './Visibility.svelte'
    import {fly} from 'svelte/transition';
</script>

<!-- styles here, omitted for brevity -->

<main>
    <h1>Scroll down</h1>

    <Visibility steps={100} let:percent let:unobserve>
        <section>
            <span class="top-left">{percent}%</span>
            <span class="top-right">{percent}%</span>
            <span class="bottom-left">{percent}%</span>
            <span class="bottom-right">{percent}%</span>

            {#if percent > 70}
                <img alt="Robot"
                     in:fly={{duration: 1000, x: -500}}
                     out:fly={{duration: 500, y: -500}}
                     src="https://robohash.org/svelte-is-awesome.png">
            {/if}
        </section>
    </Visibility>
</main>

You can see this in action, if open up your Dev tools and inspect the <img> element and you should see something like style="animation: 1000ms linear 0ms 1 normal both running __svelte_701641917_0;"

Wrapping up

I will continue to explore Svelte and I hope you will too. There's a lot I haven't talked about, one thing that comes to mind is the reactivity and the $: symbol.

But I highly recommend going through the Online Tutorial because it will explain it way better than I could and you'll be able to code along in the REPL.

Top comments (1)

Collapse
 
spiralis profile image
Ronny Hanssen

Thanks for a great article, and a cool REPL.

For my use-case I wanted it to just show the element when more than a given percentage was showing. I was also using typescript, and in my IDE the use:unobserve call yielded an error. It built fine, but the IDE was yelling. Also, I found it a bit more "advanced" to place the use: somewhere in there. Instead I made it so that it could be called from the Visibility component.

So, I added a tiny bit of code so that I could now do this:

<Visibility threshold={90} let:show>
{#if show}
  <div>Hello World</div>
{/if}
</Visibility>
Enter fullscreen mode Exit fullscreen mode

By default it would then show the element when more than 90 percent was showing and it would automatically unobserve when triggered.

Here is a forked REPL: svelte.dev/repl/9890b617365c443782..."