Back to the library
Shimmer skeleton
A loading placeholder card — avatar, two lines, a block — with a single highlight sweeping across the whole card rather than one gradient per shape. That is what makes it read as light crossing a surface instead of several unrelated bars flickering. Linear timing, because a sweep that eases looks like it is hesitating mid-pass.
loadingskeletonplaceholdershimmer
The source
A complete HTML document. Paste it into an empty .html file and it plays — no stylesheet, script, font, or image to fetch.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Shimmer skeleton</title>
<style>
:root {
--ivory: #faf9f5;
--paper: #ffffff;
--slate: #141413;
--oat: #e3dacc;
--gray-100: #f0eee6;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
min-height: 100vh;
display: grid;
place-items: center;
background: var(--ivory);
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
color: var(--slate);
}
.card {
position: relative;
width: 320px;
padding: 20px;
border-radius: 14px;
border: 1.5px solid var(--oat);
background: var(--paper);
overflow: hidden;
}
.row {
display: flex;
align-items: center;
gap: 14px;
}
.avatar {
width: 44px;
height: 44px;
border-radius: 50%;
background: var(--gray-100);
flex: none;
}
.lines {
flex: 1;
display: flex;
flex-direction: column;
gap: 9px;
}
.line {
height: 10px;
border-radius: 5px;
background: var(--gray-100);
}
.line.short {
width: 55%;
}
.block {
margin-top: 18px;
height: 76px;
border-radius: 10px;
background: var(--gray-100);
}
/* One sweep for the whole card rather than a gradient per block: the
highlight reads as light crossing a surface, which is why it holds
together. Constant motion, so linear — easing would make the pass
look like it hesitates in the middle. Only transform animates. */
.card::after {
content: '';
position: absolute;
inset: 0 auto 0 0;
width: 60%;
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0.85) 50%,
rgba(255, 255, 255, 0) 100%
);
animation: sweep 1.4s linear infinite;
}
@keyframes sweep {
from {
transform: translateX(-100%);
}
to {
transform: translateX(266%);
}
}
/* Reduced motion keeps the "not ready yet" signal but drops the travel. */
@media (prefers-reduced-motion: reduce) {
.card::after {
display: none;
}
.avatar,
.line,
.block {
animation: breathe 1.8s ease infinite;
}
@keyframes breathe {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.55;
}
}
}
</style>
</head>
<body>
<div class="card" role="status" aria-label="Loading">
<div class="row">
<div class="avatar"></div>
<div class="lines">
<div class="line"></div>
<div class="line short"></div>
</div>
</div>
<div class="block"></div>
</div>
</body>
</html>