DEV Community

Cover image for Animated Timer: The Svelte Experience
José Pablo Ramírez Vargas
José Pablo Ramírez Vargas

Posted on • Originally published at webjose.hashnode.dev

Animated Timer: The Svelte Experience

Welcome, everyone, to the introductory article of a small two-article series that aims towards demonstrating not only the incredibly beautiful experience that Svelte provides, but also the contrast against #1 favorite world-wide: React.

DISCLAIMER: I am a good developer, but I specialize in the back-end. Still, I'm a strong overall performer. Hell, I'm a great performer. This, however, doesn't guarantee that I'll be writing the best possible Svelte, or the best possible React. It doesn't matter, actually. This actually works in the series' best interest: To describe my experience while working with the Svelte framework vs working with the React library. If I were the foremost expert on any of the two, then my experience wouldn't be representative of the majority of us developers out there.

Too impatient? You can see the completed timer in the live demo at the bottom of the article.

Motivation

I am writing about this because I am in the process of trying to get Svelte approved at work. Part of my efforts include demonstrating the benefits of it when compared to what is currently approved, which is React. One of said benefits, in many people's opinion is its ease of use. I was given a time slot of 25 minutes, so I figured that a live demonstration in the form of a timer could go a long way.

Step 1: Basic Markup and Basic Styling

So first thing first: I want a timer in the form HH:MM:SS that counts down after pressing a Start button. I also want it to be inline, so I can use it in paragraphs, or as a separate element. This is just my developer reflex kicking in, always trying to create reusable code. It is not an actual requirement for my demo as the demo will use a timer as a block element.

This is what I start with:

<script>
    let hh = 0;
    let mm = 0;
    let ss = 0;
</script>

<span class="timer">
    <span class="value">
        {hh}
    </span>:<span class="value">
        {mm}
    </span>:<span class="value">
        {ss}
    </span>
</span>

<style>
    span.timer {
        padding: 0 0.2em;
    }
</style>
Enter fullscreen mode Exit fullscreen mode

This shows up as 0 :0 :0. Let's format to two digits.

Step 2: Digit Formatting

Formatting should be simple enough: Add a left-sided zero for quantities less than 10. This would be the updated component:

<script>
    let hh = 0;
    let mm = 0;
    let ss = 0;

    function f(value) {
        if (value < 10) {
            return `0${value}`;
        }
        return value.toString();
    }
</script>

<span class="timer">
    <span class="value">
        {f(hh)}
    </span>:<span class="value">
        {f(mm)}
    </span>:<span class="value">
        {f(ss)}
    </span>
</span>

<style>
    span.timer {
        padding: 0 0.2em;
    }
</style>
Enter fullscreen mode Exit fullscreen mode

This shows up as 00 :00 :00. We are now ready to make the functional aspect work.

Step 3: Add Counting Functionality

The component now needs a prop that the parent uses to tell the timer for how long it should tick. Furthermore, it needs a way to tick. If you have learned Svelte by consuming the entirety of the learning website, then you probably remember the example about the readable store. Let's use that store as means of counting! The specific lesson is here. Click the store.js file.

Ok, this is what we add now to our component:

    // Add the import line to the top of the script tag.
    import { readable } from 'svelte/store';

    // Props
    export let countFrom = 0;

    const remaining = readable(countFrom, function start(set) {
        const interval = setInterval(() => {
            set(new Date());
        }, 1000);

        return function stop() {
        clearInterval(interval);
        };
    });
Enter fullscreen mode Exit fullscreen mode

Of course, the code we borrowed set date objects in the store, and we want the remaining countdown in seconds. To modify this, it occurs to me that we can calculate an end date object whenever the prop countFrom changes. Then we count down as the difference between this calculated end time and the current time. So, let's:

    // Reactive to account for changes in countFrom:
    $: endDate = (function(secs) {
        const e = Date.now() + secs * 1000;
        return new Date(e);
    })(countFrom);
Enter fullscreen mode Exit fullscreen mode

Ok, this may be more complex-looking that what I made it sound. If you don't recognize the syntax at play here, it is an IIFE (Immediately-Invoked Function Expression). Why? You have to make sure that the reactive code written will expose the use of countFrom in the calculation to the Svelte compiler's static code analysis algorithm to ensure reactivity. This code analysis algorithm, among others, will pick up variable use when used as a function argument, so this will correctly wire up reactivity. Yes, maybe a code block works too:

    // Reactive to account for changes in countFrom:
    let endDate;
    $: {
        const e = Date.now() + countFrom * 1000;
        endDate = new Date(e);
    }
Enter fullscreen mode Exit fullscreen mode

I did not test this, but I think it satifies the reactivity requirement of endDate being assigned a new value. I like the IIFE better. I feel it to be more succint.

NOTE: At this point you should notice that we have implicitly required that countFrom be a number that represents seconds.

With the end date now calculated, let's modify our readable store:

    const remaining = readable(countFrom, function start(set) {
        const interval = setInterval(() => {
            const r = Math.round((endDate - new Date()) / 1000);
            set(r);
        }, 1000);

        return function stop() {
        clearInterval(interval);
        };
    });
Enter fullscreen mode Exit fullscreen mode

Now we can proceed to calculate the hours, minutes and seconds:

    $: hh = Math.floor($remaining / 3600);
    $: mm = Math.floor(($remaining - hh * 3600) / 60);
    $: ss = $remaining - hh * 3600 - mm * 60;
Enter fullscreen mode Exit fullscreen mode

Testing the component now will produce a fully functional timer! There is a problem, however: The counter goes beyond zero into negative values. Let's fix by not letting it and by stopping the timer once we reach zero:

    const remaining = readable(countFrom, function start(set) {
        const interval = setInterval(() => {
            let r = Math.round((endDate - new Date()) / 1000);
            r = Math.max(r, 0);
            set(r);
            if (r <= 0) {
                clearInterval(interval);
            }
        }, 1000);

        return function stop() {
        clearInterval(interval);
        };
    });
Enter fullscreen mode Exit fullscreen mode

Before calling it done, I really want to cover two things:

  1. I want a timesup event to notify the parent component that the timer finished.

  2. I want to get rid of the nasty spaces between the numbers and the colons (:).

Step 4: Finishing up

The spacing issue is very simple: Because we tend to write markup with relatively short markup lines, the resulting HTML ends up with a space because whitespace in text nodes is collapsed into a single space. So by just re-writing the markup like the following does the trick:

<span class="timer">
    <span class="value">
        {f(hh)}</span>:<span class="value">
        {f(mm)}</span>:<span class="value">
        {f(ss)}
    </span>
</span>
Enter fullscreen mode Exit fullscreen mode

Not very good-looking, but does the job. If anyone knows a better solution, I'm all ears!

The other feature is an event. Thanks to Svelte, this is super easy:

    import { createEventDispatcher } from 'svelte';

    const dispatch = createEventDispatcher();

    $: if ($remaining === 0) {
        dispatch('timesup');
    }
Enter fullscreen mode Exit fullscreen mode

Since I did not write that as shown, but instead the lines are interleaved with previous code, allow me to show the entire component file as it stands up to this point:

<script>
    import { readable } from 'svelte/store';
    import { createEventDispatcher } from 'svelte';

    // Props
    export let countFrom = 0;

    const dispatch = createEventDispatcher();

    // Reactive to account for changes in countFrom:
    $: endDate = (function(secs) {
        const e = Date.now() + secs * 1000;
        return new Date(e);
    })(countFrom);

    const remaining = readable(countFrom, function start(set) {
        const interval = setInterval(() => {
            let r = Math.round((endDate - new Date()) / 1000);
            r = Math.max(r, 0);
            set(r);
            if (r <= 0) {
                clearInterval(interval);
            }
        }, 1000);

        return function stop() {
        clearInterval(interval);
        };
    });

    $: hh = Math.floor($remaining / 3600);
    $: mm = Math.floor(($remaining - hh * 3600) / 60);
    $: ss = $remaining - hh * 3600 - mm * 60;

    $: if ($remaining === 0) {
        dispatch('timesup');
    }

    function f(value) {
        if (value < 10) {
            return `0${value}`;
        }
        return value.toString();
    }
</script>

<span class="timer">
    <span class="value">
        {f(hh)}</span>:<span class="value">
        {f(mm)}</span>:<span class="value">
        {f(ss)}
    </span>
</span>

<style>
    span.timer {
        padding: 0 0.2em;
    }
</style>
Enter fullscreen mode Exit fullscreen mode

Ok, time to test the whole thing. Let's create a parent component that:

  1. Provides a numeric input box for us to set the initial seconds for the timer.

  2. Consumes the timesup event.

This is very svelty (synonym for simple):

<script>
    import Timer from './Timer.svelte';

    let countFrom = 0;

    function handleTimesup() {
        console.log("Time's up!!");
    }
</script>

<label>Timer's Initial count:
    <input type="number" bind:value={countFrom} />
</label>
<br />
<Timer {countFrom} on:timesup={handleTimesup} />
Enter fullscreen mode Exit fullscreen mode

Great, now let's type a number in the numeric input box and ... doesn't work. Why? The readable store is constant in our code, and it is created on component creation, where the value of countFrom is zero. It needs to react to changes of countFrom. The change is trivial, again thanks to Svelte. We just remove the const keyword and replace it with Svelte's reactive syntax:

    $: remaining = readable(countFrom, function start(set) { ...
Enter fullscreen mode Exit fullscreen mode

Test again. All works as expected. Let's call this moment Milestone 1. We will define this milestone in the next article once we reach to a React equivalent.

Milestone 1 has a total of 59 lines - 12 blank or comment lines = 47 lines of code. Now, React doesn't really provide CSS and CSS is provided by third party libraries or tools, so let's also remove those lines from the count. So Milestone 1's total line count is 42.

But I Said "Animated" Timer

Ah yes, so we are not really finished. True. Animation is also not found in React, so I purposedly left it out of Milestone 1. Achieving animation will be Milestone 2. I am still debating in my head whether or not I want to learn how to animate in React, which of course would entail studying the different options out there and selecting one, then learning it, then applying it. Honestly, the "don't learn any more React" side is winning... by a lot.

Anyway, let's animate. I want that, every time the values of hh, mm, or ss change, the old values fly away in the up direction, and the new values fly in from below.

About Svelte's fly Transition

I haven't explored the source code for fly(), but I tested it quickly and things didn't fly as expected. A quick search told me that Svelte animations only work properly on block elements, not inline elements (see this SO question). This is a pickle because all of our elements in the timer are inline elements so far, but fear not as we can use CSS to make them inline-block elements.

To test our CSS setup for animation, however, we should have the actual animation in place because how can we test it if we don't have it? So let's do that first.

Adding the fly Transition

Svelte transitions only play whenever the DOM element is created or destroyed. If the contents change but the element is not removed, no transition takes place. To force the transition animation, we'll use Svelte's {#key} block.

This is the new code:

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

    // Animation-related.
    const duration = 180;
    const delay = 90;
</script>

<span class="timer">
    {#key hh}
        <span class="value" in:fly={{ delay, duration, y: '1em'}} out:fly={{ duration, y: '-1em'}}>{f(hh)}</span>
    {/key}
    :
    {#key mm}
        <span class="value" in:fly={{ delay, duration, y: '1em'}} out:fly={{ duration, y: '-1em'}}>{f(mm)}</span>
    {/key}
    :
    {#key ss}
        <span class="value" in:fly={{ delay, duration, y: '1em'}} out:fly={{ duration, y: '-1em'}}>{f(ss)}</span>
    {/key}
</span>

<style>
    span.timer {
        padding: 0 0.2em;
    }
    span.value {
        display: inline-block;
    }
</style>
Enter fullscreen mode Exit fullscreen mode

This code snippet, although it appears to be the whole component, it is not. It is not showing the rest of the JavaScript code we have written so far. Hopefully, this is not an issue and you are following the process just fine, yes?

Note how we are making our span elements animation-compatible by setting their display attribute to inline-block.

With the changes for animation, we see the extra spacing betweeen numbers and colons resurface, but that's a minor thing compared to what's going on with the animation: The timer expands to accommodate the two versions of the seconds for brief moments, making the timer grow its width. This is not good. Why does this happen?

Fixing the fly Transition

To fix the problem, let's understand the problem: Svelte, by means of the {#key} block, is instructed to create a new DOM element that will replace the previous one so the transition animation fires on value change. This means that, while transitioning, we have two versions of the same <span> element. The container, which is another <span>, grows to the right for the duration of the animation to accomodate for the extra element, as per its default behavior.

One could think that adding the CSS overflow: hidden would work here, but it wouldn't because it must be paired with a maximum or fixed width, and that would require to measure the width of the text within the child element. Too complex.

Thankfully, we have flex at our disposal. If we give the animated span elements a new parent that forbids more than one element per line, then the growing pain goes away.

This is the ammended code, which also fixes the problem of the spaces around the colons, again, using the same fix we did before:

<span class="timer">
    <span class="value">
        {#key hh}
            <span class="value" in:fly={{ delay, duration, y: '1em'}} out:fly={{ duration, y: '-1em'}}>{f(hh)}</span>
        {/key}
    </span>:<span class="value">
        {#key mm}
            <span class="value" in:fly={{ delay, duration, y: '1em'}} out:fly={{ duration, y: '-1em'}}>{f(mm)}</span>
        {/key}
    </span>:<span class="value">
        {#key ss}
            <span class="value" in:fly={{ delay, duration, y: '1em'}} out:fly={{ duration, y: '-1em'}}>{f(ss)}</span>
        {/key}
    </span>
</span>

<style>
    span.timer {
        padding: 0 0.2em;
    }
    span.value {
        display: inline-flex;
        flex-flow: column;
    }
    span.value > span {
        display: inline-block;
    }
</style>
Enter fullscreen mode Exit fullscreen mode

We have created span elements for each of the timer parts that contain the animated span element. We have also configured via CSS this new container as a flex container that accommodates the elements vertically (flex-flow: column), not horizontally.

Test again. Success! Beautiful-looking timer that we have now. We are done with Milestone 2. Or are we? We are not!

Add something below the timer. I added a paragraph:

<script>
    import Timer from './Timer.svelte';

    let countFrom = 0;

    function handleTimesup() {
        console.log("Time's up!!");
    }
</script>

<label>Timer's Initial count:
    <input type="number" bind:value={countFrom} />
</label>
<br />
<Timer {countFrom} on:timesup={handleTimesup} />
<p>The quick brown fox jumps over the lazy dog.</p> <!-- THIS -->
Enter fullscreen mode Exit fullscreen mode

Now this paragraph is the one jumping around. Why? The container grows down. Same problem, different direction. The difference here, though, is that we can easily fix the container's height with CSS:

    span.value {
        display: inline-flex;
        flex-flow: column;
        height: 1em; /* Like this!  No calculations needed. */
    }
Enter fullscreen mode Exit fullscreen mode

Now we are truly done with Milestone 2. Let's do the code line count: 66 lines with CSS, or 53 without it. Animation costed 11 lines of code, plus some CSS. Not too bad. The main factor driving the line increase was adding the {#key} blocks and the extra span elements. 8 of those 11 lines are found in the HTML markup.


Ok, so this was fun, as it is common with Svelte. If you would like to see the timer live, head to this REPL or the below live demo:

Let's meet together again in article 2 of the series, where we'll explore how to replicate this in React and then make a fair comparison between the two.

Happy coding!

Top comments (0)