Building a Stop Watch using Sveltekit and TailWindCSS

Building a Stop Watch using Sveltekit and TailWindCSS

·

14 min read

Right now I am building a study tracker project that needs a StopWatch, so I decided to write down the steps to build a StopWatch using Svelte and Tailwind.

1. Installing and configuring Tailwind

Let's start by installing Sveltekit and Tailwind.

1.1 Install Svelekit

npm create svelte@latest stopwatch
cd stopwatch
npm install
npm run dev -- --open

Just make sure to select Typescript:

1.2 Install Tailwind

Next, we will install Tailwind using the code below, but I recommend that you follow the official Tailwind documentation since things could change along the way:

Link to oficial documentation:
https://tailwindcss.com/docs/guides/sveltekit

npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init tailwind.config.cjs -p

These commands should generate both tailwind.config.cjsand postcss.config.cjsat the root of our Sveltekit project.

1.3. Enable PostCSS in <style> blocks.

To use Tailwind in the Sveltekit project we need to enable PostCSS. On older versions of Sveltekit, you may need to manually edit your svelte.config.js file and add the import vitePreprocess to enable processing <style> blocks as PostCSS.

The default svelte.config.js file already has the vitePreprocess, so you shouldn't have to do anything:

1.4. Configure your template paths

Add the paths to all of your template files in your tailwind.config.cjs file.
The content configures the src folder with default svelte file extensions:
content: ['./src/**/*.{html,js,svelte,ts}']

1.5. Add the Tailwind directives to your CSS
Inside the src folder, create a ./src/app.css file and add the @tailwind directives for each of Tailwind’s layers.

app.css
@tailwind base;
@tailwind components;
@tailwind utilities;

1.6. Import the CSS file

inside src/routes folder let's create a +layout.svelte file and import the newly-created app.css file.

<script>
    import "../app.css";
</script>

<slot />

1.7. Build the project

Now, let's test the configurations by running: npm run dev at the terminal. If everything was configured properly you should see the following output:

1.8. Start using Tailwind in your project

Now, we are ready to use Tailwind in our SvelteKit Project. Let's test if it is working by editing +page.svelte and adding this class:

<h1 class="text-3xl font-bold underline">Welcome to SvelteKit</h1>

Navigate to http://127.0.0.1:5173/ to preview Tailwind style changes on your browser.

Before:

After:

2. Styling the layout with Tailwind

With Tailwind configured let's start changing the layout of +page.svelte, but before that just make sure you empty the document and add the following code:

<script lang="ts">

</script>

<main class="flex flex-col justify-center items-center h-screen bg-gradient-to-br from-blue-500 to-red-400">
</main>

In the script tag above don't forget to add lang="ts" to use Typescript.
Also, add the h-screen tag to make sure the main div expands to the full height of the screen.

To give a nicer look for the layout we will add a gradient background using bg-gradient-to-br from the top right to the bottom right.

Next, we will add the StopWatch div widget inside our main tag.

<script lang="ts">

</script>

<main class="flex flex-col justify-center items-center h-screen bg-gradient-to-br from-blue-500 to-red-400">
    <div id="widget" class="w-2/3 h-40 bg-gray-50 p-5 rounded-lg shadow-lg bg-opacity-20" >
    </div>
</main>

Finally, we will add some centered dummy time text and title.

<script lang="ts">

</script>

<main class="flex flex-col justify-center items-center h-screen bg-gradient-to-br from-blue-500 to-red-400">
    <div id="widget" class="w-2/3 h-40 bg-gray-50 p-5 rounded-lg shadow-lg bg-opacity-20" >
        <div class="text-center">
            <h1 class="text-5xl text-white mb-5">Stop Watch</h1>
            <span class="text-5xl">00:00:00</span>
          </div>        
    </div>
</main>

The final preview on the browser should look like this:

3. Adding Buttons

For our project, we will need 4 buttons that will be visible or hidden depending on the state of the application.

  1. START

  2. RESET

  3. PAUSE

  4. RESUME

We will add these buttons below the time (hour, minutes and seconds), as well as some border, as well as other fine tunning CSS:

<main class="flex flex-col justify-center items-center h-screen bg-gradient-to-br from-blue-500 to-red-400">
    <div id="widget" class="w-2/3 h-48 bg-gray-50 p-5 rounded-2xl shadow-lg bg-opacity-20" >
        <div class="text-center">
            <h1 class="text-4xl text-white pb-5 border-b border-white-200">Stop Watch</h1>
            <div class="text-5xl mt-2 mb-2 pt-15">00:00:00</div>
                <button>Start</button>
                <button>Reset</button>
                <button>Pause</button>
                <button>Resume</button>
        </div>        
    </div>
</main>

The buttons we've just added will look ugly at first, but we will work on that later.

Let's throw some style to our buttons and push them to the right:

<main class="flex flex-col justify-center items-center h-screen bg-gradient-to-br from-blue-500 to-red-400">
    <div id="widget" class="w-2/3  h-48 bg-gray-50 p-5 rounded-2xl shadow-lg bg-opacity-20" >
        <div class="text-center">
            <h1 class="text-4xl text-white pb-3 border-b border-white-200">Stop Watch</h1>
            <div class="text-5xl mt-3 mb-2 pt-15">00:00:00</div>
                <div class="flex justify-end">
                    <button class="mr-2 text-xl px-2 p-1 border-green-200 border rounded text-green-200">Start</button>
                    <button class="mr-2 text-xl px-2 p-1 border-red-200 border rounded text-red-500">Reset</button>
                    <button class="mr-2 text-xl px-2 p-1 border-white border rounded text-white">Pause</button>
                    <button class="mr-2 text-xl px-2 p-1 border-black border rounded text-black">Resume</button>
                </div>
        </div>        
    </div>
</main>

Let's improve the buttons appearance to look little bit nicer:

                <div class="flex justify-end">
                    <button class="pl-5 pr-5 mr-2 text-xl px-2 p-1 bg-green-400  border-green-200 border rounded text-green-100">Start</button>
                    <button class="pl-5 pr-5 mr-2 text-xl px-2 p-1 bg-red-400 border-red-200 border rounded text-red-100">Reset</button>
                    <button class="pl-5 pr-5 mr-2 text-xl px-2 p-1 bg-gray-400 border-white border rounded text-gray-100">Pause</button>
                    <button class="pl-5 pr-5 mr-2 text-xl px-2 p-1 bg-orange-400 border-white border rounded text-orange-100">Resume</button>
                </div>

3. Writing code

There are several ways to implement code for each button, depending on the developer's flavor. For this project, we will use the ENUM structure to keep track of the application state since we will need to hide and show elements in the Sveltekit template code block.

<script lang="ts">
    enum STATE {
        START, 
        RUNNING,
        PAUSED
    }
    // keep track of StopWatch states
    let state: STATE = STATE.START;
</script>

Also, note that at the top level code, we have declared a variable called "state" that we will use to conditionally show or hide buttons in our template code like below:

 <div class="flex justify-end">
     {#if state === STATE.START}
        <button class="pl-5 pr-5 mr-2 text-xl px-2 p-1 bg-green-400  border-green-200 border rounded text-green-100">Start</button>
     {/if}
    {#if state === STATE.RUNNING || state === STATE.PAUSED}
        <button class="pl-5 pr-5 mr-2 text-xl px-2 p-1 bg-red-400 border-red-200 border rounded text-red-100">Reset</button>
    {/if}
    {#if state === STATE.RUNNING}
        <button class="pl-5 pr-5 mr-2 text-xl px-2 p-1 bg-gray-400 border-white border rounded text-gray-100">Pause</button>
    {/if}
    {#if state === STATE.PAUSED}
        <button class="pl-5 pr-5 mr-2 text-xl px-2 p-1 bg-orange-400 border-white border rounded text-orange-100">Resume</button>
    {/if}
</div>

Implementing the Start Button

Next, let's by setting up the button click events.

We will start with the "Start" button. For this one, we are going to create a function called onStart with an alert message for now.

<script lang="ts">
    enum STATE {
        START, 
        RUNNING,
        PAUSED
    }
    // keep track of StopWatch states
    let state: STATE = STATE.START;

    const onStart = () => {
        alert('onStart');

    }
</script>

On the template, for "Start" button, we need to bind the onStart click event using on:click={onStart}.

                    {#if state === STATE.START}
                        <button on:click={onStart} class="pl-5 pr-5 mr-2 text-xl px-2 p-1 bg-green-400  border-green-200 border rounded text-green-100">Start</button>
                    {/if}

If you click Start, you should see the following output:

After declaring the onStart function, we need to kick off an interval to keep track of some running time.

To do that, we will declare a variable initialTime to keep track of the initial time.

<script lang="ts">
    enum STATE {
        START, 
        RUNNING,
        PAUSED
    }
    // keep track of StopWatch states
    let state: STATE = STATE.START;
    let initialTime: number;

    const onStart = () => {
        initialTime = Date.now(); 
    }
</script>

Date.now() , in this case, returns the number of milliseconds elapsed since January 1, 1970 00:00:00 UTC, which can be used to represent a point in time.

Inside our onStart function, we will set this initialTime default value with Date.now() so that we can keep track of the time since we started the timer.

Next, we need to find a way to keep updating the time after we start the timer. For this, we can make use the JavaScript setInterval() that allows us to execute a function repeatedly in a defined amount of time and keep track of the elapped time.

<script lang="ts">
    enum STATE {
        START, 
        RUNNING,
        PAUSED
    }
    // keep track of StopWatch states
    let state: STATE = STATE.START;
    let initialTime: number;
    let elapsedTime: number;

    const onStart = () => {
        initialTime = Date.now();
        window.setInterval(() => {
            const currentTime = Date.now(); 
            elapsedTime = currentTime - initialTime; 
        })
    }
</script>

This setInterval() piece of code creates an interval that will run indefinitely and will update the elapsedTime variable with the time difference between currentTime and the initialTime. If the frequency is not specified, the interval will run in a fraction of milliseconds, or as quickly as the browser allows.

To verify that elapsed time changes every short small fraction of time, in our template code block, we can display the elapsedTime variable output replacing the "00:00:00" with {elapsedTime}.

<div class="text-5xl mt-3 mb-2 pt-15">{elapsedTime}</div>

<main class="flex flex-col justify-center items-center h-screen bg-gradient-to-br from-blue-500 to-red-400">
    <div id="widget" class="w-2/3  h-48 bg-gray-50 p-5 rounded-2xl shadow-lg bg-opacity-20" >
        <div class="text-center">
            <h1 class="text-4xl text-white pb-3 border-b border-white-200">Stop Watch</h1>
            <div class="text-5xl mt-3 mb-2 pt-15">{elapsedTime}</div>
                <div class="flex justify-end">
                    {#if state === STATE.START}
                        <button on:click={onStart} class="pl-5 pr-5 mr-2 text-xl px-2 p-1 bg-green-400  border-green-200 border rounded text-green-100">Start</button>
                    {/if}
                    {#if state === STATE.RUNNING || state === STATE.PAUSED}
                        <button class="pl-5 pr-5 mr-2 text-xl px-2 p-1 bg-red-400 border-red-200 border rounded text-red-100">Reset</button>
                    {/if}
                    {#if state === STATE.RUNNING}
                        <button class="pl-5 pr-5 mr-2 text-xl px-2 p-1 bg-gray-400 border-white border rounded text-gray-100">Pause</button>
                    {/if}
                    {#if state === STATE.PAUSED}
                        <button class="pl-5 pr-5 mr-2 text-xl px-2 p-1 bg-orange-400 border-white border rounded text-orange-100">Resume</button>
                    {/if}
                </div>
        </div>        
    </div>
</main>

After you click on the "Start" button the elapsed time will run but the problem is that the Start button is still visible. To fix that we need to add STATE.RUNNING to our onStart function right after we start the timer.

    const onStart = () => {
        initialTime = Date.now();
        state = STATE.RUNNING;
        window.setInterval(() => {
            const currentTime = Date.now(); 
            elapsedTime = currentTime - initialTime; 
        })
    }

Once we add state = STATE.RUNNING, only the "Pause" and "Reset" buttons should be displayed.

{#if state === STATE.RUNNING || state === STATE.PAUSED}
    <button class="pl-5 pr-5 mr-2 text-xl px-2 p-1 bg-red-400 border-red-200 border rounded text-red-100">Reset</button>
{/if}
{#if state === STATE.RUNNING}
    <button class="pl-5 pr-5 mr-2 text-xl px-2 p-1 bg-gray-400 border-white border rounded text-gray-100">Pause</button>
{/if}

Implementing the Reset Button

Now, we will work on the "Reset" button. The "Reset" button should pretty much reset the elapsed time back to zero and change the state of the application back to the START state.

<script>
...
    const onReset = () => {
        elapsedTime = 0;
        state = STATE.START;
    }
</script>
{#if state === STATE.RUNNING || state === STATE.PAUSED}
    <button on:click={onReset} class="pl-5 pr-5 mr-2 text-xl px-2 p-1 bg-red-400 border-red-200 border rounded text-red-100">Reset</button>
{/if}

One issue that you should notice is that when you click on the "Reset" Button, the "Start" button is displayed, but the timer continues running. This occurs because of setInterval() which runs indefinitely. To stop it, we need to intercept the interval not as a function but as a variable reference to reset the timer.

Luckily, in Javascript, we can keep track o intervals by assigning the setInterval() function to a variable of type number, and now we can call another javascript function called clearInterval()to set our timer to zero.

In this case, we just need to pass the stored interval as clearInterval(interval) to remove it from memory.

<script lang="ts">
    enum STATE {
        START, 
        RUNNING,
        PAUSED
    }
    // keep track of StopWatch states
    let state: STATE = STATE.START;
    let initialTime: number;
    let elapsedTime: number;
    let interval:  number;

    const onStart = () => {
        initialTime = Date.now();
        state = STATE.RUNNING;
        interval = window.setInterval(() => {
            const currentTime = Date.now(); 
            elapsedTime = currentTime - initialTime; 
        })
    }
    const onReset = () => {
        elapsedTime = 0;
        state = STATE.START;
        clearInterval(interval);
    }
</script>

This is the result after implementing clearInterval:

At some point, you may encounter this undefined message.

To fix that, we just need to initialize our initialTime and elapsedTime to zero.

    let initialTime: number = 0;
    let elapsedTime: number = 0 ;

Implementing the Pause Button

Now, let's create onPause() function. Once the Pause button is clicked, this function sets the state to STATE.PAUSED which automatically displays the "Resume" button.

This button is a little bit tricky because inside our setInterval() we need to conditionally tell the code inside of it to run only if the state is equal to STATE.RUNNING since the StopWatch is in paused state.

Just don't forget to bind it to the on:click={onPause} event of the "Pause" button.

<script>
...
    const onStart = () => {
        initialTime = Date.now();
        state = STATE.RUNNING;
        interval = window.setInterval(() => {
            if (state === STATE.RUNNING) {
                const currentTime = Date.now(); 
                elapsedTime = currentTime - initialTime; 
            }
        })
    }    
    const onPause = () => {        
        state = STATE.PAUSED;
    }
</script>

{#if state === STATE.RUNNING}
    <button on:click={onPause} class="pl-5 pr-5 mr-2 text-xl px-2 p-1 bg-gray-400 border-white border rounded text-gray-100">Pause</button>
{/if}

This is the result after clicking the "Start" button.

This is the result after clicking the "Pause" button.

Implementing the Resume Button

The resume function is pretty simple. Logically, we just need to set the state to STATE.RUNNING, right?

In this case, no. There is a huge issue if we only do this. Just wait a few seconds after clicking the "Pause" button and notice that the timer jumps to a huge number instead of continuing where it stopped.

<script>
...    
    const onResume = () => {
        state = STATE.RUNNING;
    }
</script>

{#if state === STATE.PAUSED}
    <button on:click={onResume} class="pl-5 pr-5 mr-2 text-xl px-2 p-1 bg-orange-400 border-white border rounded text-orange-100">Resume</button>
{/if}

The reason for this weird behavior is that when we clicked the "Resume" button the currentTime is now way ahead of the initial time and the variable initialTime is still stuck with the old value that was set when we clicked the "Start" button.

Because of this gap, the difference between currentTime and initialTime gets larger and larger as more time passes by and the value you see on the screen looks bigger the more you wait to click on the "Resume" button.

To fix that we need to update our initialTime by subtracting the old elapsedTime from Date.now() like this:

    const onResume = () => {
        initialTime = Date.now() - elapsedTime;
        state = STATE.RUNNING;
    }

Formatting Elapsed Time

Before formatting elapsed time we need to do some math calculations by breaking elapsedTime down into 3 parts:

  1. Seconds;

  2. Minutes;

  3. Hours;

We need to create a hours_mins_secs variable to hold formatted elapsed time as well as computed hours, minutes and seconds. We also need to add some leading padding with zero.

<script lang="ts">
    enum STATE {
        START, 
        RUNNING,
        PAUSED
    }
    // keep track of StopWatch states
    let state: STATE = STATE.START;
    let initialTime: number = 0;
    let elapsedTime: number = 0 ;
    let interval:  number;

    const pad = (number: number) => `0${number}`.slice(-2);

    $: hours = pad(Math.floor(elapsedTime / 1000 / 60 / 60) % 60);
    $: minutes = pad(Math.floor((elapsedTime / 1000) / 60) % 60);
    $: seconds = pad(Math.floor((elapsedTime / 1000) % 60));
    $: hours_mins_secs = `${hours}:${minutes}:${seconds}`;

    const onStart = () => {
        initialTime = Date.now();
        state = STATE.RUNNING;
        interval = window.setInterval(() => {
            if (state === STATE.RUNNING) {
                const currentTime = Date.now(); 
                elapsedTime = currentTime - initialTime; 
            }
        })
    }

    const onReset = () => {
        elapsedTime = 0;
        state = STATE.START;
        clearInterval(interval);
    }

    const onPause = () => {        
        state = STATE.PAUSED;
    }

    const onResume = () => {
        initialTime = Date.now() - elapsedTime;
        state = STATE.RUNNING;
    }

</script>

Since all time parts follow pretty much the same logic, we will only examine the "$: Seconds" computed variable part:

$: seconds = pad2(Math.floor((elapsedTime / 1000) % 60));

elapsedTime outputs a value in milliseconds, but to get the seconds part we need to do some math.

For example, if the elapsed time is 100,000 milliseconds, the expression (elapsedTime / 1000) would evaluate to 100 seconds. However, 100 seconds is greater than the maximum 60 seconds.

We know that 100 seconds is equivalent to 1 minute and 40 seconds and we are interested only in the seconds part, so the expression (elapsedTime / 1000) % 60 needs to return the remainder of the division of 100 by 60, which is 40 seconds.

The Math.floor function in this case is used to round the result down to the nearest integer value. This ensures that the final value is a whole number of seconds.

The use of the Math.floor function in combination with the modulus operator % and division by 1000 ensures that the final value returned is a whole number of seconds within the range of valid values for seconds.

The modulus operator % is used in the expression Math.floor((elapsedTime / 1000) % 60) to ensure that the value returned is always less than 60, which represents the number of seconds in a minute.

<main class="flex flex-col justify-center items-center h-screen bg-gradient-to-br from-blue-500 to-red-400">
    <div id="widget" class="w-2/3  h-48 bg-gray-50 p-5 rounded-2xl shadow-lg bg-opacity-20" >
        <div class="text-center">
            <h1 class="text-4xl text-white pb-3 border-b border-white-200">Stop Watch</h1>
            <div class="text-5xl mt-3 mb-2 pt-15">{hours_mins_secs}</div>
                <div class="flex justify-end">
                    {#if state === STATE.START}
                        <button on:click={onStart} class="pl-5 pr-5 mr-2 text-xl px-2 p-1 bg-green-400  border-green-200 border rounded text-green-100">Start</button>
                    {/if}
                    {#if state === STATE.RUNNING || state === STATE.PAUSED}
                        <button on:click={onReset} class="pl-5 pr-5 mr-2 text-xl px-2 p-1 bg-red-400 border-red-200 border rounded text-red-100">Reset</button>
                    {/if}
                    {#if state === STATE.RUNNING}
                        <button on:click={onPause} class="pl-5 pr-5 mr-2 text-xl px-2 p-1 bg-gray-400 border-white border rounded text-gray-100">Pause</button>
                    {/if}
                    {#if state === STATE.PAUSED}
                        <button on:click={onResume} class="pl-5 pr-5 mr-2 text-xl px-2 p-1 bg-orange-400 border-white border rounded text-orange-100">Resume</button>
                    {/if}
                </div>
        </div>        
    </div>
</main>

This is the final result:

Conclusion

Developing a StopWath is not as simple as it seems. There are a lot of nuances. The bigger advantage of using Svelte reactivity with computed variables is that we don't need to create convoluted syncronous for loops to display the data. Using Sveltekit framework allows us to reduce code complexity and maintain a cleaner UI.