A lot of modern websites handle data fetching in the browser instead of the server, this is good because the user does not need to wait too long for the page to load from the server but they then need to wait for any data to be fetched from the browser once they arrived, the data can be a blog post, form data, etc. Usually, when this process happens, the user will be shown with the spinner that indicates the data is fetched in the background. While that is a great solution, some popular websites such as Youtube or Facebook choose to not use that, instead, they use a skeleton loader screen.
The skeleton loader screen shows representation an outline of the content while it's being fetched, because of the various shapes of the skeleton they look more fun and interesting compare to a dummy animated spinner like it’s a clock.
You can see the full code here:
Skeleton Component and UX
A skeleton UI is a placeholder structured UI that represents the content as it is loading and becoming available once it's loaded. Because the skeleton mimics the page load while it's loading, the users will feel less interrupted on the overall experience. Take a look image bellow.
All of the pages above load the content at the same speed, but the empty page seems to perform worse than the other, while the skeleton page seems faster and more engaging compare to the others. The skeleton page gives the user a better experience by reducing frustration feeling while they wait for the content to load because let's be honest, no one like to wait, you can read more about research in skeleton in this amazing article.
Skeleton Component and Performance
A skeleton component can be used when we do a lazy load on our component. The lazy load purpose is to split the code that is usually not in the user's main flow at the current page and to postpone downloading it until the user needs it. Let's take look at the lazy load dialog component in Vue.
<template>
<div class="dialog">
<dialog-content />
</div>
</template>
<script>
export default {
name: 'Dialog',
components: {
DialogContent: () => import('./DialogContent.vue')
}
}
</script>
And here is the result
From the image above we know that when a user requests to download the lazy component there is a slight delay, it will become apparent if the connection of the user is slow and that's where the skeleton loader comes to play. We will use the skeleton loader to indicate that the component is being loaded and we can also combine it with The Vue async component for additional error handling.
What we’re making
The skeleton loader type that we going to make is a content placeholder, from the technical perspective we will replicate the final UI to the skeleton. From the research that has been done by Bill Chung, the participant perceive a shorter duration of the loader if:
- The skeleton has waving animation instead of static or pulsing animation,
- The animation speed is slow and steady instead of fast
- The wave animation is left to right instead of right to left
The skeleton that we are going to make should have this requirement:
- Support animation and can be controlled through component props
- Easy to customize and flexible, the shape of the skeleton can be modified through component props
Here’s a quick screenshot of what we’ll be building!
Setup Project
For this project, we will use Nuxt to play around with our code. Open up a terminal in a directory of your choice and create a new project with this command:
$ yarn create nuxt-app <project-name>
You’ll see a follow-up questionnaire like this, you can follow what we did or not, that's up to you but we suggest installing Tailwind CSS for this project, it will become make the project much easier later
create-nuxt-app v3.6.0
✨ Generating Nuxt.js project in docs
? Project name: my-skeleton
? Programming language: JavaScript
? Package manager: Yarn
? UI framework: Tailwind CSS
? Nuxt.js modules: (Press <space> to select, <a> to toggle all, <i> to invert selection)
? Linting tools: ESLint, Prettier, StyleLint
? Testing framework: None
? Rendering mode: Universal (SSR / SSG)
? Deployment target: Static (Static/Jamstack hosting)
? Development tools: (Press <space> to select, <a> to toggle all, <i> to invert selection)
? Continuous integration: None
? Version control system: Git
Create Our Component
First let's create file index.vue
page in the folder pages
to setup the main page
<template>
<div class="flex flex-wrap justify-around p-4 lg:p-16">
<Card
v-for="(item, i) in items"
:key="i"
:item="item"
:is-loaded="isLoaded"
/>
</div>
</template>
<script>
import Card from '../components/Card.vue'
export default {
name: 'Home',
components: {
Card,
},
data() {
return {
isLoaded: false,
items: [
{
thumbnail: 'laptop.svg',
avatar: 'avatar_1.jpeg',
bgColor: '#BCD1FF',
tag: 'PRODUCTIVITY',
date: '3 days ago',
title: '7 Skills of Highly Effective Programmers',
desc: 'Our team was inspired by the seven skills of highly effective programmers created by the TechLead. We wanted to provide our own take on the topic. Here are our seven...',
author: 'Glen Williams',
},
],
}
},
mounted() {
this.onLoad()
},
methods: {
onLoad() {
this.isLoaded = false
setTimeout(() => {
this.isLoaded = true
}, 3000)
},
},
}
</script>
then let's create Card.vue
file in components
folder to render each data
<template>
<div
class="flex flex-col mb-6 w-full max-w-sm bg-white rounded-2xl overflow-hidden lg:flex-row lg:mb-16 lg:mx-auto lg:max-w-screen-lg lg:h-96"
>
<div
class="flex items-center justify-center w-full h-56 lg:max-w-sm lg:h-96"
:style="{
background: item.bgColor,
}"
>
<img class="w-36 lg:w-60" :src="require(`~/assets/${item.thumbnail}`)" />
</div>
<div class="relative flex-1 p-6 pb-12 lg:p-8">
<div class="flex justify-between mb-3 lg:mb-6">
<div
class="text-gray-500 font-body text-xs font-semibold uppercase lg:text-xl"
>
{{ item.tag }}
</div>
<div class="text-gray-500 font-body text-xs lg:text-xl">
{{ item.date }}
</div>
</div>
<div class="flex flex-col">
<div class="h mb-1 font-title text-xl lg:mb-4 lg:text-4xl">
{{ item.title }}
</div>
<div class="mb-6 text-gray-900 font-body text-sm lg:text-lg">
{{ item.desc }}
</div>
</div>
<div
class=" absolute bottom-0 left-0 flex items-center justify-between pb-6 px-6 w-full lg:px-8"
>
<div class="flex items-center text-center">
<div
:style="{
backgroundImage: `url(${require(`~/assets/${item.avatar}`)})`,
}"
class="mr-3 w-8 h-8 bg-cover bg-center rounded-full lg:w-11 lg:h-11"
></div>
<div class="text-blue-500 text-xs font-semibold lg:text-xl">
{{ item.author }}
</div>
</div>
<div class="flex items-center">
<div class="mr-1 text-blue-500 text-xs font-semibold lg:text-xl">
Read More
</div>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="#3b82f6"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M16.17 13L12.59 16.59L14 18L20 12L14 6L12.59 7.41L16.17 11H4V13H16.17Z"
fill="#3b82f6"
/>
</svg>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'Card',
props: {
item: {
type: Object,
default: () => ({}),
},
isLoaded: {
type: Boolean,
default: true,
},
},
}
</script>
Now, our Card component is complete and it should look like this
The design is coming from the Card Templates by Figma Design Team, you can check the full design here
The Skeleton Component
Let's create new file namely Skeleton.vue
inside components
folder
<template>
<transition
name="skeleton"
mode="out-in"
:css="transition && hasChild ? true : false"
>
<slot v-if="isLoaded" />
<span v-else>
<span
v-for="index in rep"
:key="index"
:class="componentClass"
:style="componentStyle"
/>
</span>
</transition>
</template>
<script>
export default {
name: 'Skeleton',
props: {
animation: {
type: [String, Boolean],
default: 'wave',
validator: (val) => ['wave', false].includes(val),
},
h: {
type: String,
default: '20px',
},
isLoaded: {
type: Boolean,
default: false,
},
m: {
type: String,
default: '0px',
},
rep: {
type: Number,
default: 1,
},
radius: {
type: String,
default: '4px',
},
skeletonClass: {
type: String,
default: '',
},
transition: {
type: Boolean,
default: true,
},
w: {
type: String,
default: '100%',
},
},
computed: {
componentClass() {
return [
this.skeletonClass,
'skeleton',
this.animation ? `skeleton--${this.animation}` : null,
]
},
componentStyle() {
return {
width: this.w,
height: this.h,
borderRadius: this.radius,
margin: this.m,
}
},
hasChild() {
return this.$slots && this.$slots.default
},
},
}
</script>
The idea for the skeleton component is quite simple, we only make span
element as a skeleton to replace the main content during the load time but to make the component more reusable and functional we add a bunch of other props, let's take a close look at each of them
-
animation
- set the type of the animation of the skeleton, you can set it towave
orfalse
to disable the animation
-
h
- set the height of the skeleton, it's in string format, so you can set the value to bepx
,percentage
,vh
, orrem
-
isLoaded
- set the state for the component to show skeleton or content -
m
- set the margin of the skeleton, same as theh
props, you can set the value to various format -
rep
- repeat the skeleton component as much as the value, this will become useful if we want to create a paragraph-like skeleton -
radius
- set the border radius of the skeleton, same as theh
props, you can set the value to various format -
skeletonClass
- set class for skeleton component, use these props to add more flexibility to your component, especially when you dealing with responsive design -
transition
- set the animation during the transition of theisLoaded
component, we use Vue'stransition
component w
- set the width of the skeleton, same as theh
props, you can set the value to various format
The Styling and Animation
The next step is to add some scoped styles in the Skeleton.vue
file
.skeleton {
color: transparent;
display: block;
user-select: none;
background: #d1d5db;
* {
visibility: hidden;
}
&--wave {
position: relative;
overflow: hidden;
-webkit-mask-image: -webkit-radial-gradient(white, black);
&::after {
animation: wave 1.5s linear 0s infinite;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.5),
transparent
);
content: '';
position: absolute;
transform: translate3d(-100%, 0, 0);
will-change: transform;
bottom: 0;
left: 0;
right: 0;
top: 0;
}
}
}
@keyframes wave {
0% {
transform: translate3d(-100%, 0, 0);
}
60% {
transform: translate3d(100%, 0, 0);
}
100% {
transform: translate3d(100%, 0, 0);
}
}
.skeleton-enter-active,
.skeleton-leave-active-active {
transition: opacity 0.1s ease-in-out;
}
.skeleton-enter,
.skeleton-leave-active {
opacity: 0;
transition: opacity 0.1s ease-in-out;
}
The skeleton component styling is quite simple, we only need to add background color to the component, and the width and height are passed through the props. The waving animation is implemented by using CSS animation, the duration that we set is 1500ms and it makes the animation is slow and steady for the user. We also animate the wave animation using translate3d
and will-change
properties to achieve that 60 fps performance. Finally, let's add a simple animation effect for the transition
component, for this animation we only use the fade transition to make it simple and smooth for the user.
Implement Skeleton to Card Component
Now, let's implement the skeleton component inside our card component, the implementation of the skeleton can be in various forms, here is some of it and our thoughts about it
If Operator
The Vue's conditional rendering might be the common practice to render which component that we want to show, this method makes the code clearer and easier to maintain because the separation of the component is obvious but the downside is you need to maintain styling on the skeleton and the main component especially on flex-box and also the transition
props animation won't work in this method.
<div v-if="isLoaded">
My Awesome Content
</div>
<skeleton v-else :is-loaded="isLoaded"/>
// or
<template v-if="isLoaded">
<Card
v-for="(item, i) in items"
:key="i"
:item="item"
/>
</template>
<template v-else>
<MyCardSkeleton
v-for="(item, i) in dummyItems"
:key="i"
:item="item"
:is-loaded="isLoaded"
/>
</template>
Component Wrapper
This method is the opposite of the previous method, with this method the styling of the component is maintained and transition
props animation is working, the downside is the code might be messier because you wrap the skeleton component instead of putting it side by side to the main component.
<skeleton :is-loaded="isLoaded">
<div>
My Awesome Content
</div>
</skeleton>
For our implementation, we choose to use component wrapper method, and here is the code:
<template>
<div
class="flex flex-col mb-6 w-full max-w-sm bg-white rounded-2xl overflow-hidden lg:flex-row lg:mb-16 lg:mx-auto lg:max-w-screen-lg lg:h-96"
>
<skeleton
:animation="false"
:is-loaded="isLoaded"
skeleton-class="w-full h-56 w-36 lg:w-96 lg:h-96"
:w="null"
:h="null"
radius="0px"
>
<div
class="flex items-center justify-center w-full h-56 lg:max-w-sm lg:h-96"
:style="{
background: item.bgColor,
}"
>
<img
class="w-36 lg:w-60"
:src="require(`~/assets/${item.thumbnail}`)"
/>
</div>
</skeleton>
<div class="relative flex-1 p-6 pb-12 lg:p-8">
<div class="flex justify-between mb-3 lg:mb-6">
<skeleton
skeleton-class="w-28 h-4 lg:h-7"
:w="null"
:h="null"
:is-loaded="isLoaded"
>
<div
class="text-gray-500 font-body text-xs font-semibold uppercase lg:text-xl"
>
{{ item.tag }}
</div>
</skeleton>
<skeleton
skeleton-class="w-24 h-4 lg:h-7"
:w="null"
:h="null"
:is-loaded="isLoaded"
>
<div class="text-gray-500 font-body text-xs lg:text-xl">
{{ item.date }}
</div>
</skeleton>
</div>
<div class="flex flex-col">
<skeleton
:is-loaded="isLoaded"
skeleton-class="w-full h-7 lg:h-9"
class="mb-3"
:w="null"
:h="null"
>
<div class="h mb-1 font-title text-xl lg:mb-4 lg:text-4xl">
{{ item.title }}
</div>
</skeleton>
<skeleton
class="mb-6"
:is-loaded="isLoaded"
skeleton-class="w-full h-3 lg:h-5"
:w="null"
:h="null"
m="0 0 8px 0"
:rep="4"
>
<div class="mb-6 text-gray-900 font-body text-sm lg:text-lg">
{{ item.desc }}
</div>
</skeleton>
</div>
<div
class="absolute bottom-0 left-0 flex items-center justify-between pb-6 px-6 w-full lg:px-8"
>
<div class="flex items-center text-center">
<skeleton
:is-loaded="isLoaded"
skeleton-class="w-8 h-8 lg:w-11 lg:h-11"
:w="null"
:h="null"
radius="100%"
class="mr-3"
>
<div
:style="{
backgroundImage: `url(${require(`~/assets/${item.avatar}`)})`,
}"
class="mr-3 w-8 h-8 bg-cover bg-center rounded-full lg:w-11 lg:h-11"
></div>
</skeleton>
<skeleton
:is-loaded="isLoaded"
skeleton-class="w-16 h-4 lg:h-7 lg:w-28"
:w="null"
:h="null"
>
<div class="text-blue-500 text-xs font-semibold lg:text-xl">
{{ item.author }}
</div>
</skeleton>
</div>
<skeleton
:is-loaded="isLoaded"
skeleton-class="w-16 h-4 lg:h-7 lg:w-28"
:w="null"
:h="null"
>
<div class="flex items-center">
<div class="mr-1 text-blue-500 text-xs font-semibold lg:text-xl">
Read More
</div>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="#3b82f6"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M16.17 13L12.59 16.59L14 18L20 12L14 6L12.59 7.41L16.17 11H4V13H16.17Z"
fill="#3b82f6"
/>
</svg>
</div>
</skeleton>
</div>
</div>
</div>
</template>
<script>
import Skeleton from './Skeleton.vue'
export default {
name: 'Card',
components: {
Skeleton,
},
props: {
item: {
type: Object,
default: () => ({}),
},
isLoaded: {
type: Boolean,
default: true,
},
},
}
</script>
In our implementation, we mainly set skeleton-class
props to set the height and weight of the skeleton to use the utility class in tailwind CSS, this utility class is become handy when dealing with responsive design.
Skeleton on Lazy Load Component
Lazy load component usually can be done by using import()
function, but because it's asynchronous, we don't know when the component is finished being fetched.
export default {
components: {
DialogContent: () => import('./DialogContent.vue')
}
}
Luckily, Vue has a feature for this problem, we can loading components as the component is being fetched and error component if the main component is failed, you can read more here.
const DialogContent = () => ({
// The component to load (should be a Promise)
component: import('./DialogContent.vue'),
// A component to use while the async component is loading
loading: SkeletonDialogContent,
// A component to use if the load fails
error: DialogFailed,
// The error component will be displayed if a timeout is
// provided and exceeded. Default: Infinity.
timeout: 3000,
})
Here is the end result, you can read the code in the GitHub repo
Wrapping it up
We already learn how to create a skeleton component and how to implement it in Vue. Skeleton can improve UX in your site if it's implemented in the right case, you need to know the behavior of the user and the goals of the page before implementing the skeleton component.
I hope this post helped give you some ideas, please do share your feedback within the comments section, I'd love to hear your thoughts!
Top comments (0)