Search code examples
sveltesvelte-transition

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:

  • "Translation" of how I would do it in vanilla JS (seems hacky)
  • Svelte transitions (causes layout shift)
  • pure CSS (seems like the simplest and most effective to me)

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:

  • I have to bind the Component's top-most element to a variable (root) in order to query things inside it (seems hacky, so there's got to be something I don't understand; normally I would just use document.querySelectorAll(), but that apparently isn't the way to do it in Svelte).
  • TypeScript gets mad at the root variable, but I wouldn't begin to know what type to assign it.

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>


Solution

  • 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>