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.cjs
and postcss.config.cjs
at 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.
START
RESET
PAUSE
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:
Seconds;
Minutes;
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.