Almost every tutorial about a CSS loading animation stops at the same place: here is a spinning circle, copy the keyframes, done. What they rarely tell you is that a badly implemented loading screen can make your site feel slower, push your Largest Contentful Paint later, and create layout shift the moment the content finally appears.
This tutorial takes the opposite approach. You will build a lightweight preloader and a skeleton screen with pure CSS, hide them with roughly ten lines of vanilla JavaScript, and apply the rules that keep the animation off the browser’s main thread. No spinner library, no animation framework, no extra kilobytes. This discussion raises a few points we skipped.
Quick answer: the minimum viable CSS loading animation
If you only need the code, here it is. One element, one keyframe, no dependencies:
<div class="spinner" role="status" aria-label="Loading"></div>
<style>
.spinner {
width: 48px;
height: 48px;
border-radius: 50%;
border: 4px solid rgba(0, 0, 0, 0.12);
border-top-color: #0b63f6;
animation: spinner-rotate 0.8s linear infinite;
}
@keyframes spinner-rotate {
to { transform: rotate(360deg); }
}
</style>
That is the whole spinner. The rest of this article is about the part that actually matters: where you put it, when you remove it, and how to keep it from damaging your performance metrics.

First decide: do you even need a loading screen?
A full-screen preloader hides your content on purpose. On a fast connection, that means you are deliberately delaying the moment the user sees something useful. Use this table as a decision guide before you write a single line of CSS.
| Expected wait | What to show | Why |
|---|---|---|
| Under 100 ms | Nothing | The action feels instant. An indicator that flashes for 80 ms only creates visual noise. |
| 100 ms to 1 s | Subtle inline state (button spinner, dimmed area) | Confirms the click was registered without blocking the page. |
| 1 s to 10 s | Skeleton screen or spinner | Users need proof that something is happening. Skeletons win for content, spinners for actions. |
| Over 10 s | Determinate progress bar with text | An infinite spinner for 12 seconds reads as “broken”, not “loading”. |
Rule of thumb: content pages should almost never use a full-screen preloader. Dashboards, editors, canvas apps and anything that needs to boot before it is usable are the legitimate use cases.
Part 1: a full-screen preloader in pure CSS
Step 1: the markup
Put the overlay as the first element inside the body, before your app markup. It must be able to paint before anything else.
<body>
<div id="preloader" class="preloader" role="status" aria-live="polite">
<div class="preloader__spinner" aria-hidden="true"></div>
<span class="visually-hidden">Loading page content</span>
</div>
<main id="app">
<!-- your real content -->
</main>
</body>
Step 2: inline the critical CSS
This is the step most tutorials skip. If your preloader styles live in an external stylesheet that arrives late, the user sees an unstyled flash of your raw HTML first. Put the overlay CSS inline in the <head>, and keep it tiny.
<head>
<style>
.preloader {
position: fixed;
inset: 0;
z-index: 9999;
display: grid;
place-items: center;
background: #ffffff;
opacity: 1;
transition: opacity 0.3s ease, visibility 0.3s ease;
}
.preloader.is-hidden {
opacity: 0;
visibility: hidden;
pointer-events: none;
}
.preloader__spinner {
width: 44px;
height: 44px;
border-radius: 50%;
border: 4px solid #e3e8ef;
border-top-color: #0b63f6;
animation: preloader-rotate 0.8s linear infinite;
}
@keyframes preloader-rotate {
to { transform: rotate(360deg); }
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
}
@media (prefers-reduced-motion: reduce) {
.preloader__spinner {
animation: preloader-fade 1.4s ease-in-out infinite;
}
@keyframes preloader-fade {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
}
</style>
</head>
Notice three details:
- The animation only touches transform, which the browser can composite without repainting.
- The exit uses opacity plus visibility, not
display: none, so the fade actually renders. - Users who requested reduced motion get a gentle opacity pulse instead of a rotating element.
Step 3: hide it with a few lines of vanilla JavaScript
No library needed. Listen for the load event, add a class, then remove the node from the DOM so it never intercepts clicks.
(function () {
var preloader = document.getElementById('preloader');
if (!preloader) return;
function hidePreloader() {
if (preloader.classList.contains('is-hidden')) return;
preloader.classList.add('is-hidden');
document.body.classList.remove('is-loading');
setTimeout(function () {
if (preloader.parentNode) preloader.parentNode.removeChild(preloader);
}, 400);
}
window.addEventListener('load', hidePreloader);
// Safety net: never trap the user behind the overlay
setTimeout(hidePreloader, 5000);
})();
The safety net timeout is not optional. If a third party script hangs, a slow font never resolves, or an image 404s in a way that stalls the load event, your visitors would stare at a spinner forever. Five seconds is a reasonable ceiling.
Step 4: handle the no-JavaScript case
If JavaScript is blocked or fails to parse, the overlay stays on screen and your site looks dead. Add a pure CSS escape hatch that fades the preloader out on its own:
.preloader {
animation: preloader-selfdestruct 0s linear 8s forwards;
}
@keyframes preloader-selfdestruct {
to { opacity: 0; visibility: hidden; }
}
Or, even simpler and stricter: render the overlay itself from a <noscript>-aware class that JavaScript adds to <html> at the very top of the page. If JS never runs, the overlay is never shown. There is more on it in The Progress CSS Loaders Collection.

Part 2: the skeleton screen (usually the better choice)
A skeleton screen shows the shape of the content before the content exists. Research on perceived performance consistently shows that people rate skeletons as faster than spinners for the same real duration, because the interface looks like it is already assembling itself.
Skeleton markup
<article class="card" aria-busy="true">
<div class="skeleton skeleton--thumb"></div>
<div class="skeleton skeleton--line" style="width: 80%"></div>
<div class="skeleton skeleton--line" style="width: 95%"></div>
<div class="skeleton skeleton--line" style="width: 60%"></div>
</article>
Skeleton CSS with a compositor-friendly shimmer
.skeleton {
position: relative;
overflow: hidden;
background: #e9edf2;
border-radius: 6px;
}
.skeleton--thumb {
aspect-ratio: 16 / 9;
margin-bottom: 12px;
}
.skeleton--line {
height: 0.9em;
margin-bottom: 8px;
}
.skeleton::after {
content: "";
position: absolute;
inset: 0;
transform: translateX(-100%);
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0.65) 50%,
rgba(255, 255, 255, 0) 100%
);
animation: skeleton-shimmer 1.4s infinite;
}
@keyframes skeleton-shimmer {
to { transform: translateX(100%); }
}
@media (prefers-reduced-motion: reduce) {
.skeleton::after { animation: none; }
}
Many popular skeleton snippets animate background-position on a huge gradient. That forces the browser to repaint the element on every frame. Animating transform on a pseudo element does the same thing visually and stays on the compositor.
Swapping the skeleton for real content
async function loadArticles() {
const list = document.querySelector('#list');
list.setAttribute('aria-busy', 'true');
const res = await fetch('/api/articles');
const data = await res.json();
list.innerHTML = data.map(renderArticle).join('');
list.setAttribute('aria-busy', 'false');
}
The layout shift trap (and how to avoid it)
This is where most loading states quietly cost you Core Web Vitals points. If your skeleton is 120px tall and the real card is 168px tall, every card on the page jumps when the data arrives. That is Cumulative Layout Shift, and Google measures it.
Four rules that eliminate the shift
- Match the box, not the vibe. The skeleton block must have the same width, height, padding and margins as the final element.
- Use
aspect-ratiofor media. A skeleton thumbnail withaspect-ratio: 16 / 9reserves the exact space the image will occupy. - Size text placeholders in
em. A skeleton line atheight: 0.9emwith the sameline-heightas your paragraph text lands where the text will land. - Set
min-heighton containers that can be empty. Lists, tables and comment sections should never collapse to zero and then expand.
Quick self-test
Open Chrome DevTools, go to Rendering, tick Layout Shift Regions, throttle the network to Slow 4G, and reload. Any blue flash when the content replaces the placeholder is a bug you can fix with the rules above.
Performance rules for any CSS loading animation
A loading animation runs precisely when the browser is busiest: parsing, fetching, decoding, executing. That is the worst possible moment to hand it expensive work.
| Animate this (cheap) | Avoid this (expensive) | What the browser must redo |
|---|---|---|
transform |
width, height, top, left, margin |
Full layout recalculation on every frame |
opacity |
box-shadow, filter: blur(), large background-position |
Heavy repaint, often on a large surface |
| Single element with pseudo elements | Twenty animated divs with staggered delays | More layers, more compositing memory |
Additional guardrails
- Do not load a font just for the loader. A spinner that waits for a webfont defeats its own purpose. Use shapes and borders, or a system font stack.
- Do not use a GIF or an animated SVG sprite. A CSS loading animation weighs zero extra bytes over the wire once the stylesheet is inlined.
- Use
will-changesparingly. It promotes an element to its own layer. On one small spinner it is fine, on fifty skeleton rows it eats memory. In most casestransformalready gets the layer it needs. - Stop the animation when it is off screen. Infinite animations keep the compositor busy. Remove the node or set
animation-play-state: pausedonce loading ends. - Never delay the LCP element behind an overlay you control. If your hero image is ready at 900 ms but your preloader hides it until 2.4 s, you have manufactured a bad LCP score with your own code.

Accessibility: the part nobody copies from CodePen
A rotating div means nothing to a screen reader. Three small additions make the loading state announce itself correctly:
role="status"on the container, so assistive technology announces the change politely without interrupting.- Visually hidden text such as “Loading page content” or “Loading search results”, because the visual animation carries no text.
aria-busy="true"on the region being updated, flipped tofalsewhen the real content lands.prefers-reduced-motionsupport. Spinning and shimmering elements can trigger discomfort for people with vestibular conditions. Fall back to a static or gently fading placeholder.
Three more single element loaders you can copy
Bouncing dots
.dots {
display: inline-flex;
gap: 6px;
}
.dots span {
width: 8px;
height: 8px;
border-radius: 50%;
background: #0b63f6;
animation: dots-bounce 0.9s infinite ease-in-out;
}
.dots span:nth-child(2) { animation-delay: 0.15s; }
.dots span:nth-child(3) { animation-delay: 0.3s; }
@keyframes dots-bounce {
0%, 80%, 100% { transform: translateY(0); opacity: 0.5; }
40% { transform: translateY(-6px); opacity: 1; }
}
Indeterminate progress bar
.bar {
position: relative;
height: 3px;
overflow: hidden;
background: #e3e8ef;
}
.bar::before {
content: "";
position: absolute;
inset: 0;
width: 40%;
background: #0b63f6;
animation: bar-slide 1.1s ease-in-out infinite;
}
@keyframes bar-slide {
0% { transform: translateX(-100%); }
100% { transform: translateX(250%); }
}
Inline button spinner
.btn[data-loading="true"] {
pointer-events: none;
color: transparent;
position: relative;
}
.btn[data-loading="true"]::after {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 16px;
height: 16px;
margin: -8px 0 0 -8px;
border: 2px solid rgba(255, 255, 255, 0.35);
border-top-color: #ffffff;
border-radius: 50%;
animation: preloader-rotate 0.7s linear infinite;
}
The button keeps its original width because the label is still there, just transparent. That is a one line fix for a very common layout shift.

Common mistakes checklist
| Mistake | Consequence | Fix |
|---|---|---|
| Artificial minimum display time | You slow down fast visitors on purpose | Hide as soon as the content is ready |
| Preloader CSS in an external file | Flash of unstyled content before the overlay paints | Inline the few rules in the head |
| Overlay left in the DOM after hiding | Invisible layer swallows clicks and keeps a compositing layer alive | Remove the node after the transition |
| Skeleton dimensions guessed | Layout shift and a worse CLS score | Match sizes with aspect-ratio and em units |
| No timeout fallback | One failing third party script traps every visitor | Force hide after a few seconds |
| Infinite spinner for long jobs | Users assume the app crashed and reload | Show progress or a status message |
How to verify your loading state is actually helping
- Throttle to Slow 4G with 4x CPU slowdown in DevTools and reload. This is closer to a real mid range phone than your desktop.
- Record a Performance trace and check that the frames during loading stay green. Long purple layout bars mean you are animating the wrong property.
- Enable Layout Shift Regions and confirm nothing flashes when placeholders are replaced.
- Run Lighthouse before and after adding the preloader. If LCP got worse, your overlay is hiding your own hero content for too long.
- Test with JavaScript disabled to confirm the page is still usable.
- Turn on reduced motion in your operating system settings and reload.
Frequently asked questions
How do you make a loading animation in CSS?
Create an element, give it a visible shape (a bordered circle, a bar, a few dots), then define a @keyframes rule that changes transform or opacity over time and attach it with the animation shorthand. A rotating spinner needs only animation: spin 0.8s linear infinite; plus @keyframes spin { to { transform: rotate(360deg); } }.
Can you do animation with CSS alone, without JavaScript?
Yes. CSS handles the entire visual animation through @keyframes and transition. JavaScript is only needed to decide when the loading state should disappear, and that takes about ten lines: listen for the load event or your fetch completion, toggle a class, remove the node.
How do I create a spinning animation in CSS?
Use transform: rotate() inside keyframes. Rotate from 0 to 360 degrees with linear timing so the rotation speed stays constant, and use infinite so it repeats until you remove it. Rotating a circle with a single differently coloured border side is the classic and cheapest spinner.
Does a CSS animation slow down a website?
It depends entirely on what you animate. Animations on transform and opacity run on the compositor and are nearly free. Animations on width, height, top, margin, box-shadow or filter force layout or paint work on every frame and can drop frames on low end devices. The bigger risk with loading screens is not the animation itself but the overlay hiding your content longer than necessary.
Skeleton screen or spinner: which one should I use?
Use a skeleton when you know the shape of the content that is coming (lists, cards, articles, tables). Use a spinner when the outcome has no predictable shape, such as a form submission, a payment confirmation, or a background job. Never use both in the same region at the same time.
How long should a preloader stay on screen?
Exactly as long as the wait, and no longer. Never add a fake minimum duration to “show off” the animation. Do add a hard maximum of a few seconds after which the overlay removes itself, so a failing asset never blocks the whole page.
Do I need a loader library for this?
For 95 percent of use cases, no. A spinner is one element and five CSS declarations. A skeleton is a background colour, a border radius and one animated pseudo element. Shipping a JavaScript animation package to draw a rotating circle adds weight during the exact moment your page is already struggling.
Wrap up
A good CSS loading animation is not the one with the most impressive keyframes. It is the one that appears instantly, communicates clearly, animates only compositor friendly properties, reserves the exact space the real content will take, respects reduced motion preferences, and disappears the moment it is no longer needed.
Start with the inline spinner or the skeleton above, add the ten lines of vanilla JavaScript, run the six verification steps, and you will have a loading state that makes your site feel faster instead of merely looking busy.
