DEV Community

Akbar Nafisa
Akbar Nafisa

Posted on

Skeleton Component in UX and Performance in Vue

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.

Image description
Image description

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.

Image description

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>
Enter fullscreen mode Exit fullscreen mode

And here is the result

Image description

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!
Image description

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

Now, our Card component is complete and it should look like this

Image description
Image description

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>
Enter fullscreen mode Exit fullscreen mode

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 to wave or false to disable the animation

Image description

  • h - set the height of the skeleton, it's in string format, so you can set the value to be px, percentage, vh, or rem
  • isLoaded - set the state for the component to show skeleton or content
  • m - set the margin of the skeleton, same as the h 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 the h 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 the isLoaded component, we use Vue's transition component

    Image description

  • w - set the width of the skeleton, same as the h 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;
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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.

Image description
Image description

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')
    }
}
Enter fullscreen mode Exit fullscreen mode

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,
})
Enter fullscreen mode Exit fullscreen mode

Here is the end result, you can read the code in the GitHub repo

Image description
Image description

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!

Resource

Discussion (0)