DEV Community

Cover image for Let's create a nice button component with Loading, Done and Error states using Svelte and Tailwind πŸŽ‰
Alex Tana
Alex Tana

Posted on

Let's create a nice button component with Loading, Done and Error states using Svelte and Tailwind πŸŽ‰

Perceived performance is the illusion of speed we experience when a website is really good at letting us know exactly what's happening at all times.

Today I'm going to show you how to create a button component that you can re-use and is able to give the user meaningful information whilst being nicely animated; we will then use this button to fetch Pokemon using the poke API.

This is the end result:

Code + Preview

Preview

Before we start πŸ‘¨β€πŸ’»

I'm going to be assuming you've already set up your environment with Svelte and Tailwind CSS, if you haven't you can follow this guide by swyx to help you out.

Let's start πŸŽ‰

We're going to create a Button component in src/components/ui/buttons/Button.svelte or whatever directory you prefer.

Now let's import the button component where we want it to be displayed - example here

<script>
    import Button from '../components/ui/buttons/Button.svelte';
</script>

<Button>My button</Button>
Enter fullscreen mode Exit fullscreen mode

Let's now set up the states for our button in our Button.svelte and our index page, the four states we are going to have are the default, loading, error and done state.

index.svelte (or wherever your button is displayed)

Our index file is where we render the Button component, here we're going to handle the click event and control its appearance - to do this we use component props. They look like custom HTML attributes and we use them to send data from the parent index.svelte to the child Button.svelte

Let's now add all of our possible button states and initialise them as false. Initialising variables is always recommended as it gives you an idea of what kind of value they hold, in this case they're booleans

<script>
    // button states
    let isLoading = false;
    let isError = false;
    let isDone = false;
</script>
<Button
    loading={isLoading}
    error={isError}
    done={isDone}
>
    Catch Pokemon
</Button>
Enter fullscreen mode Exit fullscreen mode

And let's create three props to control its appearance

<Button
    loading={isLoading}
    error={isError}
    done={isDone}
    loadingClass="bg-yellow-600 scale-110 active:bg-yellow-600"
    errorClass="bg-red-600 scale-110 shake active:bg-red-600"
    doneClass="bg-green-600 scale-110 active:bg-green-600"
>
    Catch Pokemon
</Button>
Enter fullscreen mode Exit fullscreen mode

Don't worry too much about the shake class for now, we're going to create the CSS for it later.

If you're not familiar with it, all of these classes except for shake are tailwindcss classes - more info on them here.

Button.svelte

In our Button component we're then going to use svelte's own export let yourVariable to read what the parent component is sending to us - note they're also initialised with a value so when our component is mounted we know what they are, they can be manipulated from index.svelte.

Initialising these class variables with an empty string '' prevents rendering class names of undefined in our HTML on mount.

Let's now add these state initialisation variables and a default base class for our button:

<script>
    // class variables
    export let loadingClass = '';
    export let errorClass = '';
    export let doneClass = '';
    // state variables
    export let loading = false;
    export let error = false;
    export let done = false;
</script>

<button
    class="transition-all overflow-hidden transform relative text-white px-4 py-3 rounded-lg shadow-lg"
>
    <slot/>
</button>
Enter fullscreen mode Exit fullscreen mode

Now using ternary operators we can conditionally set a class based on which of the three states we're in

If you're not familiar with ternary operators here's how they work:

{
    loading ? loadingClass : '';
}
Enter fullscreen mode Exit fullscreen mode

this means if loading is true use loadingClass else use an empty string ''

Let's add these in! πŸ‘

<button
    class="transition-all overflow-hidden transform relative text-white px-4 py-3 rounded-lg shadow-lg {loading
        ? loadingClass
        : ''} {error ? errorClass : ''} {done ? doneClass : ''} {loading || error || done
        ? 'pr-8 pl-4'
        : 'bg-blue-400 hover:bg-blue-600'}
  "
    on:click|preventDefault
>
    <slot />
</button>
Enter fullscreen mode Exit fullscreen mode

Notice I've added an on:click|preventDefault attribute on it, this means we can now use on:click events directly on our Button component in index.svelte

{loading || error || done ? 'pr-8 pl-4' : 'bg-blue-400 hover:bg-blue-600'}

This line sets the default background + hover and changes the padding if any of the states is true (the right padding change will be needed for our icon)

Let's add our icons to Button.svelte!

Source: Heroicons

I've picked three icons from the web for this - Don't exactly remember the sources for all of them so please let me know in the comments if you know who made these!

We're going to want these icons to be animated and to appear/disappear based on our loading/error/done states so let's add our code with transitions right after our slot

Let's import fly from svelte transitions and quintInOut from svelte easing to animate them

import { fly } from 'svelte/transition';
import { quintInOut } from 'svelte/easing';
Enter fullscreen mode Exit fullscreen mode

and let's create a default class for all the icons to position them correctly

<script>
  import {fly} from 'svelte/transition';
  import {quintInOut} from 'svelte/easing';
  // class variables
  export let loadingClass = '';
  export let errorClass = '';
  export let doneClass = '';
  // state variables
  export let loading = false;
  export let error = false;
  export let done = false;
  let iconClass = 'absolute right-2 top-2/4 transform -translate-y-2/4 ';
</script>
Enter fullscreen mode Exit fullscreen mode

Our icon will have position absolute, relative to its button parent and vertically aligned in the middle thanks to the utility classes top-2/4 transform -translate-y-2/4

Creating our icons!

Let's now add our icons to our Button.svelte component right after our slot tag

We are going to need an If block for our different states

{#if loading}
  <span class={iconClass}>
    loading icon here
  </span>
{:else if error}
  <span class={iconClass}>
    error icon here
  </span>
{:else if done}
  <span class={iconClass}>
    done icon here
  </span>
{/if}
Enter fullscreen mode Exit fullscreen mode

We're wrapping them in a span tag so we can use a svelte transition attribute on them.

This is the code for all the icons with the styles:


<button
    class="transition-all overflow-hidden transform relative text-white px-4 py-3 rounded-lg shadow-lg {loading
        ? loadingClass
        : ''} {error ? errorClass : ''} {done ? doneClass : ''} {loading || error || done
        ? 'pr-8 pl-4'
        : 'bg-blue-400 hover:bg-blue-600'}
  "
    on:click|preventDefault
>
    <slot />

    {#if loading}
        <span
            in:fly|local={{ duration: 600, y: 30, easing: quintInOut }}
            out:fly|local={{ duration: 300, y: 30 }}
            class={iconClass}
        >
            <svg class="spinner" viewBox="0 0 50 50">
                <circle class="path" cx="25" cy="25" r="20" fill="none" stroke-width="5" />
            </svg>
        </span>
    {:else if error}
        <span
            in:fly|local={{ duration: 600, x: 30, easing: quintInOut }}
            out:fly|local={{ duration: 300, x: 30 }}
            class={iconClass}
        >
            <svg
                xmlns="http://www.w3.org/2000/svg"
                class="h-5 w-5 fill-current"
                viewBox="0 0 20 20"
                fill="currentColor"
            >
                <path
                    fill-rule="evenodd"
                    d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"
                    clip-rule="evenodd"
                />
            </svg>
        </span>
    {:else if done}
        <span
            in:fly|local={{ duration: 600, x: 30, easing: quintInOut }}
            out:fly|local={{ duration: 300, x: 30 }}
            class={iconClass}
        >
            <svg
                xmlns="http://www.w3.org/2000/svg"
                class="h-5 w-5"
                viewBox="0 0 20 20"
                fill="currentColor"
            >
                <path
                    fill-rule="evenodd"
                    d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
                    clip-rule="evenodd"
                />
            </svg>
        </span>
    {/if}
</button>

<style>
    .spinner {
        animation: rotate 2s linear infinite;
        z-index: 2;
        width: 20px;
        height: 20px;
        z-index: 15;
    }
    .path {
        stroke: white;
        stroke-linecap: round;
        animation: dash 1.5s ease-in-out infinite;
    }
    @keyframes rotate {
        100% {
            transform: rotate(360deg);
        }
    }
    @keyframes dash {
        0% {
            stroke-dasharray: 1, 150;
            stroke-dashoffset: 0;
        }
        50% {
            stroke-dasharray: 90, 150;
            stroke-dashoffset: -35;
        }
        100% {
            stroke-dasharray: 90, 150;
            stroke-dashoffset: -124;
        }
    }
    .shake {
        animation: shake 0.82s cubic-bezier(0.36, 0.07, 0.19, 0.97);
        transform: translate3d(0, 0, 0);
        backface-visibility: hidden;
        perspective: 1000px;
    }
    @keyframes shake {
        10%,
        90% {
            transform: translate3d(-2px, 0, 0);
        }

        20%,
        80% {
            transform: translate3d(4px, 0, 0);
        }

        30%,
        50%,
        70% {
            transform: translate3d(-6px, 0, 0);
        }

        40%,
        60% {
            transform: translate3d(6px, 0, 0);
        }
    }
</style>
Enter fullscreen mode Exit fullscreen mode

We're using different duration values for in and out because we want the animation to leave quicker than it comes in to leave room for the next icon to take the spotlight.

the shake and spinner classes are for the error animation and the spinner respectively, you can use any other icon here, this is just as an example.

NICE πŸ₯³

Our button component is now finished and it should look like this:

<script>
    import { fly } from 'svelte/transition';
    import { quintInOut } from 'svelte/easing';
    // class variables
    export let loadingClass = '';
    export let errorClass = '';
    export let doneClass = '';
    // state variables
    export let loading = false;
    export let error = false;
    export let done = false;

    let iconClass = 'absolute right-2 top-2/4   transform -translate-y-2/4 ';
</script>

<button
    class="transition-all overflow-hidden transform relative text-white px-4 py-3 rounded-lg shadow-lg {loading
        ? loadingClass
        : ''} {error ? errorClass : ''} {done ? doneClass : ''} {loading || error || done
        ? 'pr-8 pl-4'
        : 'bg-blue-400 hover:bg-blue-600'}
  "
    on:click|preventDefault
>
    <slot />

    {#if loading}
        <span
            in:fly|local={{ duration: 600, y: 30, easing: quintInOut }}
            out:fly|local={{ duration: 300, y: 30 }}
            class={iconClass}
        >
            <svg class="spinner" viewBox="0 0 50 50">
                <circle class="path" cx="25" cy="25" r="20" fill="none" stroke-width="5" />
            </svg>
        </span>
    {:else if error}
        <span
            in:fly|local={{ duration: 600, x: 30, easing: quintInOut }}
            out:fly|local={{ duration: 300, x: 30 }}
            class={iconClass}
        >
            <svg
                xmlns="http://www.w3.org/2000/svg"
                class="h-5 w-5 fill-current"
                viewBox="0 0 20 20"
                fill="currentColor"
            >
                <path
                    fill-rule="evenodd"
                    d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"
                    clip-rule="evenodd"
                />
            </svg>
        </span>
    {:else if done}
        <span
            in:fly|local={{ duration: 600, x: 30, easing: quintInOut }}
            out:fly|local={{ duration: 300, x: 30 }}
            class={iconClass}
        >
            <svg
                xmlns="http://www.w3.org/2000/svg"
                class="h-5 w-5"
                viewBox="0 0 20 20"
                fill="currentColor"
            >
                <path
                    fill-rule="evenodd"
                    d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
                    clip-rule="evenodd"
                />
            </svg>
        </span>
    {/if}
</button>

<style>
    .spinner {
        animation: rotate 2s linear infinite;
        z-index: 2;
        width: 20px;
        height: 20px;
        z-index: 15;
    }
    .path {
        stroke: white;
        stroke-linecap: round;
        animation: dash 1.5s ease-in-out infinite;
    }
    @keyframes rotate {
        100% {
            transform: rotate(360deg);
        }
    }
    @keyframes dash {
        0% {
            stroke-dasharray: 1, 150;
            stroke-dashoffset: 0;
        }
        50% {
            stroke-dasharray: 90, 150;
            stroke-dashoffset: -35;
        }
        100% {
            stroke-dasharray: 90, 150;
            stroke-dashoffset: -124;
        }
    }
    .shake {
        animation: shake 0.82s cubic-bezier(0.36, 0.07, 0.19, 0.97);
        transform: translate3d(0, 0, 0);
        backface-visibility: hidden;
        perspective: 1000px;
    }
    @keyframes shake {
        10%,
        90% {
            transform: translate3d(-2px, 0, 0);
        }

        20%,
        80% {
            transform: translate3d(4px, 0, 0);
        }

        30%,
        50%,
        70% {
            transform: translate3d(-6px, 0, 0);
        }

        40%,
        60% {
            transform: translate3d(6px, 0, 0);
        }
    }
</style>
Enter fullscreen mode Exit fullscreen mode

it's now time to go back to our index.svelte file to fetch our Pokemons!

Use pokeAPI to get our Pokemons πŸ›

Consuming APIs in Svelte is really easy, we are going to use the fetch API and svelte's await blocks to do the job.

your index file should look something like this at this point

<script>
    import Button from '../components/ui/buttons/Button.svelte';

    // button states
    let isLoading = false;
    let isError = false;
    let isDone = false;
</script>

<div class="flex my-8 justify-center">
    <Button
        loading={isLoading}
        error={isError}
        done={isDone}
        loadingClass="bg-yellow-600 scale-110 active:bg-yellow-600"
        errorClass="bg-red-600 scale-110 shake active:bg-red-600"
        doneClass="bg-green-600 scale-110 active:bg-green-600"
    >
  Catch Pokemon
    </Button>
</div>

Enter fullscreen mode Exit fullscreen mode

First of all, let's add some initial state to hold our pokemons

let pokemons = null;
Enter fullscreen mode Exit fullscreen mode

this pokemons variable will be populated with the response from our API call, let's now browse to the pokeAPI website to check how to query for what we want.

For the purpose of this tutorial we will only fetch 20 pokemons but you'll be able to adjust the limit to your liking.

Here's our endpoint with our query
https://pokeapi.co/api/v2/pokemon?limit=20

let's create a variable for the limit and let's also add a delay one we're going to use for our state changes

let pokemons = null;
let limit = 20;
let delay = 2000;
Enter fullscreen mode Exit fullscreen mode

now that our variables are set we can proceed with creating a function that will fetch our data, since fetch returns a promise, we can use async/await to get our pokemons

async function fetchPokemon() {
    const response = await fetch(`https://pokeapi.co/api/v2/pokemon?limit=${limit}`);   return response.json();
}
Enter fullscreen mode Exit fullscreen mode

note that we're using our limit variable to set how many pokemons we want to fetch; meaning we're probably never going to have to touch this function again.

Nothing is happening yet, we still have to call our function, let's call it when we click on our Button component and let's also move it to the centre of the page.

To move it to the centre we'll just wrap it in a flex div and justify its content to the centre, like so

<div class="flex my-8 justify-center">
    <Button
        loading="{isLoading}"
        error="{isError}"
        done="{isDone}"
        loadingClass="bg-yellow-600 scale-110 active:bg-yellow-600"
        errorClass="bg-red-600 scale-110 shake active:bg-red-600"
        doneClass="bg-green-600 scale-110 active:bg-green-600"
    >
        Catch Pokemon
    </Button>
</div>
Enter fullscreen mode Exit fullscreen mode

and add an on click event to it, with a function that we still have to write called handleButton

<Button
  on:click={handleButton}
  ...
Enter fullscreen mode Exit fullscreen mode

before we write the function we can add different text based on the state, like so:

<Button
        on:click={handleButton}
        loading={isLoading}
        error={isError}
        done={isDone}
        loadingClass="bg-yellow-600 scale-110 active:bg-yellow-600"
        errorClass="bg-red-600 scale-110 shake active:bg-red-600"
        doneClass="bg-green-600 scale-110 active:bg-green-600"
    >
        {#if isLoading}
            Catching Pokemons...
        {:else if isError}
            You've already caught 'em all
        {:else if isDone}
            Got 'em!
        {:else}
            Catch Pokemon
        {/if}
</Button>
Enter fullscreen mode Exit fullscreen mode

the handleButton function

this function is what is going to control what happens when you press the Button component, I'm going to use setTimeouts to artificially delay the loading state, this is because our 20 pokemon request is usually super quick and you wouldn't be able to see the state at all othwerwise - ideally the loading state should change to "done" right after the response comes from the API.

Let's write the function

function handleButton() {
    // we only fetch once on this demo
    // this is so we can display "error"
    // if someone tries to fetch twice
    if (!pokemons) {
        // this sets our pokemons variable
        // to the API response
        pokemons = fetchPokemon();
        // set loading state
        isLoading = true;
        // reset loading state
        setTimeout(() => {
            isLoading = false;
            isDone = true;
            // return to default
            setTimeout(() => {
                isDone = false;
            }, delay);
        }, delay);
    } else {
        // if I've already fetched then
        // switch to error state
        isError = true;
        setTimeout(() => {
            isError = false;
        }, delay);
    }
}
Enter fullscreen mode Exit fullscreen mode

Another way of doing this without the artificial delay would be adding the loading state to the fetchPokemon function and using a reactive state, just as a quick example:

$: if (pokemons?.length) {
    isLoading = false;
}

async function fetchPokemon() {
    isLoading = true;
    const response = await fetch(`https://pokeapi.co/api/v2/pokemon?limit=${limit}`);
    return response.json();
}
Enter fullscreen mode Exit fullscreen mode

Display our pokemons

There are several ways to do this but a nice and simple way is to use svelte's await blocks

{#await promise}
  Loading message...
{:then result}
  Use your {result}
{:catch error}
  Handle the error {error}
{/await}
Enter fullscreen mode Exit fullscreen mode

I'm going to be use grid to display the pokemons and a fade transition for the safety check, first let's check if the pokemons variable is populated

{#if pokemons}
  <div
        transition:fade={{ duration: 800, easing: quintInOut }}
        class="grid grid-cols-2 lg:grid-cols-5 gap-8 my-8"
    >
  {#await pokemons}
    Loading...
  {:then result}
    Use your {result}
  {:catch error}
    Handle the error {error}
  {/await}
  </div>
{/if}
Enter fullscreen mode Exit fullscreen mode

the fade transition will happen when the condition is met so when pokemons is not a falsy value

All we need to do now is to create an each loop using svelte's each blocks to loop through our results and render each individual pokemon, let's get inside {:then result}

  {#await pokemons}
            Loading...
    {:then result}
        {#each result.results as pokemon, i}
      <div
        class="border border-gray-600 p-8 rounded-xl text-white bg-gray-800 hover:bg-gray-900 shadow-lg capitalize"
        transition:fly={{ duration: 200, y: 30, delay: i * 100 }}
      >
        <h3 class="text-2xl font-extrabold">{pokemon.name}</h3>
        <h5 class="text-base">Pokemon #{i + 1}</h5>
      </div>
    {/each}
    {:catch error}
        An error has occurred {error}
    {/await}
Enter fullscreen mode Exit fullscreen mode

let's break this down:

result will be our response object, as you can see from here

what we want from this object is the key results which holds all our 20 pokemons, so this is how we loop through them:

{#each result.results as pokemon, i}
  individual pokemon here {pokemon.name}
{#each}
Enter fullscreen mode Exit fullscreen mode

i would be our index, but we can also use this to identify the pokemon number, which will be useful to grab the relative image for each one of them, we just need to make a simple change.

Indexes start at 0 in javascript but our first pokemon would be 1, all we need to do is add 1 to our index to find out our Pokemon number.

to fetch the images I've had a look at a sample pokemon response from here and found that the image URLs follow this pattern:

https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/{pokemonNumber}.png

where pokemonNumber woud be our i + 1 in our loop.

All together should look like this:

{#if pokemons}
    <div
        transition:fade={{ duration: 800, easing: quintInOut }}
        class="grid grid-cols-2 lg:grid-cols-5 gap-8 my-8"
    >
        {#await pokemons}
            Loading...
        {:then result}
            {#each result.results as pokemon, i}
                <div
                    class="border border-gray-600 p-8 rounded-xl text-white bg-gray-800 hover:bg-gray-900 shadow-lg capitalize"
                    transition:fly={{ duration: 200, y: 30, delay: i * 100 }}
                >
                    <img
                        src="https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/{i +
                            1}.png"
                        alt={pokemon.name}
                    />
                    <h3 class="text-2xl font-extrabold">{pokemon.name}</h3>
                    <h5 class="text-base">Pokemon #{i + 1}</h5>
                </div>
            {/each}
        {:catch error}
            An error has occurred {error}
        {/await}
    </div>
{/if}
Enter fullscreen mode Exit fullscreen mode

ALL DONE! πŸ‘πŸ‘

Our very simple button state application is now finished, the end result is here if you need any help referencing the code.

Hope this guide will help you get more familiar with Svelte, thanks for checking it out and let me know in the comments if you have any ideas to improve it further!

Discussion (0)