Editor’s Note: This post was reviewed for accuracy on 8 March 2023. Learn more about improving React UX with skeleton loaders using React and Framer Motion.
With recent evolutions to the web, things are getting better both for developers and users. User experience is a vast topic beyond current measures because new ways of improving user experiences continue to evolve with time.
One part of improving UX are skeleton screens. In this article, we’ll explore skeleton loaders, their importance, and how to build one for your site using React and vanilla CSS.
To jump ahead:
Let’s examine a case study, and then three scenarios.
Case study: You see the link to this article, and decide to click on it. The data needs some time to arrive.
Scenario 1: As you wait for the site to display the article, all you see is a blank screen. You don’t know how the screen will be displayed when it loads, and you don’t know when either.
Scenario 2: As the data loads, all you see is a spinner to indicate that data is on its way. You know that something is coming after the load, but you don’t know what it will look like or when it will arrive, either.
Scenario 3: As the page loads, you notice these gray shapes that take on the structure of what the page is supposed to look like. As you wait, you already know what the page might look like. You don’t know when the data will come, but the situation keeps you expecting that it’ll arrive sooner than it actually does.
The last scenario is part of something called Perceived Performance. This is the impression that a site is loading faster than it actually is because the user already has an idea of what it will look like.
Below is an example of a skeleton loader for Facebook.
As you can see, the gray shapes represent how the data will be displayed once it loads. When the data finally arrives from the server, it replaces the gray shapes on the screen.
Skeleton loaders don’t exactly increase the performance or loading speed. They’re just there to give the user something to see while the page loads, and give them the feeling that the page is loading faster.
Now, let’s see why skeleton loaders are important and when you should use them.
The question now is, when should you use skeleton loaders for your site?
You might choose to use skeleton loaders if:
For the tutorial of this article, we are going to create a simple page that displays articles in the form of cards for a particular blogger. Our end result is going to look like the following.
The skeleton view:
The actual site:
This tutorial is very beginner friendly, but there are some details you need to check so that you can freely follow.
We are going to be set up our React app pretty differently in this tutorial. But first, let’s get our directories set up nicely.
mkdir cssLoader-React
and then cd cssLoader-React
to navigate into the newly created directorynpx create-react-app
, we will use a tool called Vite to set this up. Vite is a build tool that helps you optimize your developer environment. It is lightweight and very fastnpm create vite@latest vite-project -- --template react
into the terminal. npm create vite@latest
adds Vite to our project, and the lines following it describe the name of our project and install React for usThe following instructions ask us to run cd vite-project
to navigate into the newly created directory, npm install
to add a node_modules so that our dependencies are installed, and npm run dev
to start the server.
If you’ve used Vite to set this up, then you’ll realize how amazingly fast it is to whip up a React app from scratch.
Open the folder in any text editor of your choice. Our app is set up, and now it’s time to add and remove what we don’t need from our boilerplate. We start by clearing up everything from the App component and the default index.css file. We want to create two new folders: a folder called components
inside the /src
folder and another called server
in the root directory. Our new project structure looks like this:
The reason we have a server folder is that we are going to set up a database-like structure where we can store data about the blogs. Don’t worry, we won’t be going into any backend. We will set up a fake REST API with JSON Server.
So in the /server
folder, let’s create a file called db.json
and populate it with some random data you would normally see on a blog like an avatar, author name, thumbnail, title, and description. Notice that the /server
folder is created in the top-level directory, or root directory.
Back in our db.json
file, the contents should look a bit like this:
Each top-level property is considered a resource, and endpoints are created for each resource.
Using the JSON Server package, we need to watch this file so that we can create endpoints to it, and other operations like POST, GET, DELETE, etc., that we may want to perform (not covered in this article).
To do that, open a new terminal and run npx json-server -watch server/db.json -port 5000
. If it runs successfully, then the terminal should start the server on port 5000, and start watching any changes.
If you copy and paste the http://localhost:5000 in a browser, you’ll see that the JSON file has been hosted on localhost. You should also see http://localhost:5000/blogs as a resource.
That being done, we can then get to the coding. To follow up alongside the code, you can check out the complete files and folders here.
To start off, let’s fetch the data from our local server first. After successfully fetching and handling any fetch errors, we will create a template to display the data.
So, in our app.jsx
:
import { useState, useEffect } from 'react'; const App = () => { const [blogs, setBlogs] = useState(null); const [error, setError] = useState(false); const [loading, setLoading] = useState(true); useEffect(() => { setTimeout(() => { fetch(' http://localhost:5000/blogs') .then(response => { if(!response.ok){ throw Error('Sorry, some error occurred while fetching your blogs.'); } return response.json(); }) .then(data => { setBlogs(data); setLoading(false); setError(false); }) .catch(err => { console.log(err.message); setError(true); }) }, 4000) }) return( <div> {blogs && <Blogs blogs = {blogs} /> } {error && <div className='container'><span className='error'>Error connecting to the server. Connection failed.</span></div>} </div> ) } export default App;
There are going to be a few elements on our page that change as we fetch and display data, and so we need the useState
hook to track these elements. We start by initializing our blogs to null. If the data does not come back, then we need to get an error. Using useState
again, we set the initial state of error
to false. Another thing we need to initialize is the loading
state. This was set to true beforehand because the data needs to be loaded before it comes back to us.
We use the useEffect
hook to now fetch our data once the page first renders. We wrap everything in a setTimeOut
function, so as to simulate the fetch process. Normally, you wouldn’t do this in a real-life situation. We need that small window of four seconds to see the skeleton loader.
We use a traditional fetch to get data from our resource, and tackle an error if the status code is anything other than 200. If the data is in good form, we parse the response to JSON. Since this is another promise, we need to resolve it using another .then
method. Here, we change the state of blogs from null to the actual data received from the server. Since the data has been received and is no longer ‘loading’, we change the state of loading
from true to false. If data is returned, then there is no error, so the state of error
also changes.
Inside the return() of the component, we have:
<div> {blogs && <Blogs blogs = {blogs} /> } {error && <div className='container'><span className='error'>Error connecting to the server. Connection failed.</span></div>} </div>
The first line simply means that, if blogs
has been returned and is true, render the component <Blogs />
with the returned data (in this case, blogs) passed to it. The logical AND (&&) makes sure that the second statement is only executed if the first statement is true. The same goes for the second line. If the state of error
is true, we have a div that will display an error message.
We will come back to the aspect of loading
shortly.
Now, we’ll create an actual Blogs
component. Inside the /components
folder, create a new component file called blogs.jsx
.
const Blogs = ({blogs}) => { return( <div className="container"> {blogs.map(blog => ( <div className="blog"> <header> <div className="avatar"> <img src={blog.avatar} alt="black and white photo of smiling man infront of laptop" /> </div> <div className="author"> <p className="name">{blog.author}</p> </div> </header> <main className="image"> <img src={blog.thumbnail} alt="black screen with code over it" /> </main> <footer> <p className="title">{blog.title}</p> <p className="text">{blog.description}</p> </footer> </div> ))} </div> ); } export default Blogs;
In this component, we pass in blogs
as a prop, and go further to create a template for the different properties of our blog like blog.title
, blog.description
, etc.
We got rid of the styling in our index.css
, so we can replace it with:
@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@300&display=swap'); /* blogs display */ .container{ display: grid; grid-template-columns: 6fr 6fr; margin: 4em; font-family: 'Poppins', sans-serif; } .blog{ margin-bottom: 2em; } header{ display:flex; align-items: center; } .author .name{ font-weight: bold; font-size: large; margin-left: 1rem; } .title{ font-weight:bold; font-size:30px; } .text{ font-weight: lighter; font-size: large; line-height:1em; width: 25em; } footer{ line-height: 1rem; } .avatar img{ height: 80px; width: 80px; border-radius: 50%; object-fit: cover; } .image img{ height: 70%; width: 70%; border-radius: 8px; } .error{ color: red; }
This is nothing but a grid layout and font. Import the Blog
component inside the App
component, and our blog site should now look like this:
For the skeleton loader, we’ll need to create a skeleton structure first. Inside the /components
folder, let’s create a new component called skeleton.jsx
.
const Skeleton = () => { return( <div className="skeleton-blog"> <header> <div className="skeleton-avatar"> </div> <div className="skeleton-author"> </div> </header> <main className="skeleton-image"> </main> <footer className="skeleton-footer"> </footer> </div> ); }; export default Skeleton;
These are nothing but empty divs that we will style to take the shape of our actual site.
Still in our index.css
:
/* Skeleton styles*/ .skeleton-blog{ margin-bottom: 2em; animation: scale 4s infinite; } .skeleton-avatar{ background: #E5E4E2; height: 60px; width: 60px; border-radius: 50%; } .skeleton-author{ background: #E5E4E2; height: 30px; width: 150px; margin-left: 1rem; border-radius: 6px; } .skeleton-image{ height: 200px; width: 320px; border-radius: 6px; background: #E5E4E2; margin-top: 10px; } .skeleton-footer{ height: 30px; width: 280px; border-radius: 6px; background: #E5E4E2; margin-top: 10px; }
Back in our App
component, we want to do two things. The first thing is to import the skeleton component at the top, and the next is to find where and how to add the skeletons.
<div className="container"> {loading && [1,2,3,4,5,6,7].map((n) => <Skeleton key={n}/>)} </div>
Just as we implemented the AND
operator before, we will do the same here. Remember that loading
was initially set to true. So while loading
is true, we want to display the skeletons as we wait for the data to arrive. We wrap it in a div with a class of container, so that it links to the .container
in our index.css and gives our skeleton cards the same grid layout.
You will notice that we have an array situation going on. Your array can contain any number of elements you want. In our case, we want to display seven skeleton cards while our data is loading. Note that skeleton loaders are not there to exactly replace elements on your page – they are just temporary placeholders. If your site fetches 1000 articles, displaying 1000 cards seems pretty ambitious.
In effect, our skeleton loaders should look like this while the page loads:
After 4000 milliseconds (4 seconds), as set by our setTimeOut
function, the actual blogs should be loaded from the server and displayed.
Some skeleton loaders have nice, shimmering animations that swiftly breeze over the page. Let’s try to add a simple scaling animation to our loader to give it a nice zoom-in and out effect.
We can achieve this by adding one line of CSS to our .skeleton-blog
class and then adding a @keyframe
.
.skeleton-blog{ animation: scale 4s infinite; }
The above will animate each skeleton card with some animation called scale
, which we have defined below.
/* Skeleton animation*/ @keyframes scale { 0% {transform: scale(0.9)} 100% {transform: scale(1)} }
These combined will create a scaling animation infinitely (as long as you wait for the data), with a duration of four seconds.
All these put together give us the following result:
I hope that this article has made creating a skeleton loader easier and much more comprehensible. We have gone over what loaders are, why they are important, and a nice, simple way to make your own.
You can check out the code for this tutorial here. Feel free to make customizations, style it however you please, and make improvements to the code.
As web frontends get increasingly complex, resource-greedy features demand more and more from the browser. If you’re interested in monitoring and tracking client-side CPU usage, memory usage, and more for all of your users in production, try LogRocket.
LogRocket is like a DVR for web and mobile apps, recording everything that happens in your web app, mobile app, or website. Instead of guessing why problems happen, you can aggregate and report on key frontend performance metrics, replay user sessions along with application state, log network requests, and automatically surface all errors.
Modernize how you debug web and mobile apps — start monitoring for free.
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowLearn how to manage memory leaks in Rust, avoid unsafe behavior, and use tools like weak references to ensure efficient programs.
Bypass anti-bot measures in Node.js with curl-impersonate. Learn how it mimics browsers to overcome bot detection for web scraping.
Handle frontend data discrepancies with eventual consistency using WebSockets, Docker Compose, and practical code examples.
Efficient initializing is crucial to smooth-running websites. One way to optimize that process is through lazy initialization in Rust 1.80.