DevinG
DevinG

Reputation: 341

How to animate Svelte component on page load without causing a layout shift

Problem

I would like to animate a header once the landing page of my SvelteKit app is loaded, causing each letter of the heading to appear one slightly after the other, but all the solutions I've found for this either require some things that feel hacky and make TypeScript upset or result in layout shift.

Simplified Structure of heading I'm wanting to animate

    <h1>
        <span>C</span><span>A</span><span>T</span>
    </h1>

Solutions so far:

I'm hoping to see if there's a more "Svelte-y" way of achieving the effect without negative side-effects.

How I would do this in vanilla JS

In vanilla JS, I would simply use css to set the <h1> element's opacity to zero and give its opacity a transition value. Then I would define a class that sets opacity to 1. In my script, I would my set an interval on page load that incrementally adds a class to the <span> elements within the heading. That way, each letter would appear in succession and fade into view, and there would be no layout shift.

Svelte "translation" of above approach makes TypeScript upset

Transferring the above vanilla JS approach to Svelte introduces a few weird things:

Component's Code


    <script>
        import { onMount } from "svelte";
        let root;
    
        const lettersWithClasses = { C: 'red', A: 'green', T: 'orange' };
    
        const animateHeading = function(letters) {
            letters.forEach((span, i) => {
                setTimeout(() => {
                    span.classList.add("viz")
                }, i * 400 + 1000);
            });
        }
    
        onMount(() => {
            let headingLetters = root.querySelectorAll("#heading > span");
            animateHeading(headingLetters);
        });
    </script>
    
    <div bind:this={root}>
        <h1 id="heading" class="h1 text-center text-[12rem] font-extrabold">
            {#each Object.entries(lettersWithClasses) as [key, value], i}
                <span
                    class="{value}"
                >{key}</span>
            {/each}
        </h1>
    </div>
    
    <style>
        h1 > span {
            opacity: 0;
            transition: opacity 1s ease-in;
        }
        .viz {
            opacity: 1 !important;
        }
        .red { color: red; }
        .green { color: green; }
        .orange { color: orange; }
    </style>

Using Svelte Transitions (causes layout shift)

This seems like a more Svelte-y way of achieving my goal (see use of the fade Svelte transition), but it results in layout shift :(

Also, it seems really hacky the way I'm changing the value of this ready variable via onMount but it's necessary in order to get the transition to run once the page is loaded (maybe it shouldn't be considered "hacky" though since Rich Harris is who suggested this method).

What is for sure is that layout shift = bad


    <script>
        import { onMount } from "svelte";
        import { fade } from "svelte/transition";
        const lettersWithClasses = { C: 'red', A: 'green', T: 'orange' };

        let ready = false;
        onMount(() => ready = true);
    </script>
    
    <div>
        {#if}
        <h1 id="heading" class="h1 text-center text-[12rem] font-extrabold">
            {#each Object.entries(lettersWithClasses) as [key, value], i}
                <span
                    in:fade|global={{ delay: 1000 + i * 400, duration: 1000 }}
                    class="{value}"
                >{key}</span>
            {/each}
        </h1>
        {/if}
    </div>
    
    <style>
        .red {
            color: #f70702;
        }
        .green {
            color: #398c31;
        }
        .orange {
            color: #f27202;
        }
    </style>

I've thought about adding a hard-coded <span> after the closing {/each} tag and giving it a class that makes it have a visibility of "hidden", but that seems hacky to me too.

Just CSS

Would it be better to just use CSS keyframes to do all this? By "better" I mean both less complicated for me as the developer and less computationally expensive for the client. Or is there a "better" way that is sort of more baked-in to Svelte?

example of how to do all this with CSS

Just assign each <span> a different class and then @keyframes and animation to run the transition. Btw, this CSS method was the only method to fetch a perfect 100 lighthouse score in all categories.


    <script>
        const lettersWithClasses = { C: 'red', A: 'green', T: 'orange' };
    </script>
    
    <div>
        <h1 id="heading" class="h1 text-center text-[12rem] font-extrabold">
            {#each Object.entries(lettersWithClasses) as [key, value], i}
                <span
                    class="{value}"
                >{key}</span>
            {/each}
        </h1>
    </div>
    
    <style>
        @keyframes fadeinto-red {
            from { opacity: 0; color: #white; }
            to { opacity: 1; color: red; }
        }
        @keyframes fadeinto-green {
            from { opacity: 0; color: white; }
            to { opacity: 1; color: green; }
        }
        @keyframes fadeinto-orange {
            from { opacity: 0; color: white; }
            to { opacity: 1; color: orange; }
        }
        span.red{
            animation: fadeinto-red 1.8s ease-in-out both;
        }
        span.green{
            animation: fadeinto-green 1.8s ease-in-out 0.4s both;
        }
        span.orange{
            animation: fadeinto-orange 1.8s ease-in-out 0.8s both;
        }
    </style>

Upvotes: 3

Views: 913

Answers (1)

Corrl
Corrl

Reputation: 7721

This would be a modification of your first example that

  • avoids the 'root binding' by using an action
  • avoids the extra color classes with the help of the style: directive

REPL

<script>
    const lettersWithClasses = { C: 'red', A: 'green', T: 'orange' };

    function fadeIn(spanElement, index) {
        setTimeout(() => {
            spanElement.style.opacity = 1
        }, index * 400 + 1000);
    }
</script>

<div>
    <h1 id="heading" class="h1 text-center text-[12rem] font-extrabold">
        {#each Object.entries(lettersWithClasses) as [letter, color], index}
            <span
                style:color={color}
                use:fadeIn={index}
                >
                {letter}
            </span>
        {/each}
    </h1>
</div>

<style>
    h1 > span {
        opacity: 0;
        transition: opacity 1s ease-in;
    }
</style>

The pure CSS version with @keyframes animation could be made more universal with the use of CSS custom properties which I guess is then the cleaner an preferable way over the js action solution

REPL

<script>
    const lettersWithClasses = { C: 'red', A: 'green', T: 'orange' };
</script>

<div>
    <h1 id="heading" class="h1 text-center text-[12rem] font-extrabold">
        {#each Object.entries(lettersWithClasses) as [letter, color], i}
            <span
                style="--end-color: {color}; --delay: {i*400}ms;"
                class="animate"
                >{letter}</span>
        {/each}
    </h1>
</div>

<style>
    @keyframes fadeinto {
        from { opacity: 0; color: white; }
        to { opacity: 1; color: var(--end-color); }
    }
    .animate {
        animation: fadeinto 1.8s ease-in-out var(--delay) both;
    }
</style>

Upvotes: 2

Related Questions