VueJS loading button with TailwindCSS

Constantin Druc ยท 01 Mar, 2022

Loading buttons. We need them in every project. We submit the form, it takes a while to finish, so we display a spinner or something as feedback that says, "Hey, I am working, just give me a second to complete."

So in this post, we'll take a look over how we can make a super simple, clean, loading button that, when active, shows a spinner in the middle of it.

The first thing I'm going to do is style the button to make it pretty.

1<template>
2 <button class="px-4 py-2 text-sm font-medium rounded text-white bg-rose-600 hover:bg-rose-600/80">
3 Update profile
4 </button>
5</template>

Next up, we need to add the SVG icon representing the loader. Then, we'll set a width and a height, make it white, and then we'll make it spin using the animate-spin class.

1<template>
2 <button class="px-4 py-2 text-sm font-medium rounded text-white bg-rose-600 hover:bg-rose-600/80">
3
4 <svg class="w-5 h-5 text-white animate-spin" fill="none"
5 viewBox="0 0 24 24"
6 xmlns="http://www.w3.org/2000/svg">
7 <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
8 <path class="opacity-75"
9 d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
10 fill="currentColor"></path>
11 </svg>
12
13 Update profile
14 </button>
15</template>

What I want to do is to position the spinner in the middle of the button. One solution is to add relative to our button and then make the spinner absolute, push it to the right 50% of the way using left-1/2, and then drag it back half of its size. Since our spinner is w-5, we need to add a negative left margin of -ml-5. This will place the spinner dead center.

Now, all we need to do is hide the text and show the spinner when the button is loading. To do that, I'll add an isLoading reactive value, set its default to true, and then use v-show to toggle the spinner and the button label (which we'll wrap within a span tag).

1<script setup>
2const isLoading = ref(true);
3</script>
4 
5<template>
6 <button class="px-4 py-2 text-sm font-medium rounded text-white bg-rose-600 hover:bg-rose-600/80">
7 <svg v-show="isLoading" class="w-5 h-5 text-white animate-spin" fill="none"
8 viewBox="0 0 24 24"
9 xmlns="http://www.w3.org/2000/svg">
10 <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
11 <path class="opacity-75"
12 d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
13 fill="currentColor"></path>
14 </svg>
15 <span v-show="isLoading">Update profile</span>
16 </button>
17</template>

However, when v-show is true, it basically translates to display:none;, which breaks our button because there's nothing inside it to maintain its size. To fix that, instead of using v-show to toggle the button label, we'll toggle the invisible class.

1<script setup>
2const isLoading = ref(true);
3</script>
4 
5<template>
6 <button class="px-4 py-2 text-sm font-medium rounded text-white bg-rose-600 hover:bg-rose-600/80">
7 <svg v-show="isLoading" class="w-5 h-5 text-white animate-spin" fill="none"
8 viewBox="0 0 24 24"
9 xmlns="http://www.w3.org/2000/svg">
10 <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
11 <path class="opacity-75"
12 d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
13 fill="currentColor"></path>
14 </svg>
15 <span :class="{'invisible': isLoading}">Update profile</span>
16 </button>
17</template>

Next up, we should turn isLoading into a prop passed by whoever uses this loading button component. We can do that using the defineProps vue compiler macro.

1<script setup>
2defineProps({
3 isLoading: {
4 type: Boolean,
5 default: false
6 }
7});
8</script>
9 
10<template>(Click to show)
11 <button class="px-4 py-2 text-sm font-medium rounded text-white bg-rose-600 hover:bg-rose-600/80">
12 <svg v-show="isLoading" class="w-5 h-5 text-white animate-spin" fill="none"
13 viewBox="0 0 24 24"
14 xmlns="http://www.w3.org/2000/svg">
15 <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
16 <path class="opacity-75"
17 d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
18 fill="currentColor"></path>
19 </svg>
20 <span :class="{'invisible': isLoading}">Update profile</span>
21 </button>
22</template>

Another thing we should do is allow whoever uses the loading button component to set the button label. And we can do that by replacing the current "Update profile" text with a slot tag.

1<script setup>
2defineProps({ (Click to show)
3 isLoading: {
4 type: Boolean,
5 default: false
6 }
7});
8</script>
9 
10<template>
11 <button class="px-4 py-2 text-sm font-medium rounded text-white bg-rose-600 hover:bg-rose-600/80">
12 <svg v-show="isLoading" class="w-5 h-5 text-white animate-spin" fill="none"viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> (Click to show)
13 <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
14 <path class="opacity-75"
15 d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
16 fill="currentColor"></path>
17 </svg>
18 <span :class="{'invisible': isLoading}">
19 <slot></slot>
20 </span>
21 </button>
22</template>

Finally, currently, there is no way to change the button's color. We should look inside the component, decide which styles might vary, and extract them outside the component. In our case, we want to remove the following classes: text-white bg-rose-600 hover:bg-rose-600/80.

1<script setup>
2defineProps({
3 isLoading: {
4 type: Boolean,
5 default: false
6 }
7});
8</script>
9 
10<template>
11 <button class="px-4 py-2 text-sm font-medium rounded">
12 <svg v-show="isLoading" class="w-5 h-5 text-white animate-spin" fill="none"
13 viewBox="0 0 24 24"
14 xmlns="http://www.w3.org/2000/svg">
15 <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
16 <path class="opacity-75"
17 d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
18 fill="currentColor"></path>
19 </svg>
20 <span :class="{'invisible': isLoading}">
21 <slot></slot>
22 </span>
23 </button>
24</template>

This will allow us to set these styles from the outside:

1// Some other component
2 
3<LoadingButton :isLoading="isSubmitting" class="text-white bg-black hover:bg-black/80">
4 Save changes
5</LoadingButton>

And that's it; I hope you found this post useful! Check the full snippets in the "Code" section above.