Skeleton Loader
A content card that opens as shimmering skeleton placeholders — avatar, text lines, image block — swept by an animated gradient, then crossfades to real content after a short delay. A Reload button replays the load; under reduced-motion the shimmer stops and content shows at once.
- CSS gradient sweep
- crossfade opacity
- prefers-reduced-motion
Source
A single self-contained .astro component. It uses Prism's design tokens (--spectrum-*, --ink…) with sensible fallbacks, so it renders even outside this site.
---
export const meta = {
title: "Skeleton Loader",
tags: ["transitions", "loading", "shimmer", "skeleton"],
description:
"A content card that opens as shimmering skeleton placeholders — avatar, text lines, image block — swept by an animated gradient, then crossfades to real content after a short delay. A Reload button replays the load; under reduced-motion the shimmer stops and content shows at once.",
tech: ["CSS gradient sweep", "crossfade opacity", "prefers-reduced-motion"],
interactive: true,
height: 420,
};
---
<section class="skl-stage">
<article class="skl-card" data-skl data-state="loading" aria-busy="true">
<!-- Skeleton layer -->
<div class="skl-skeleton" data-skl-skeleton aria-hidden="true">
<div class="skl-row">
<div class="skl-bone skl-avatar"></div>
<div class="skl-stack">
<div class="skl-bone skl-line" style="width: 55%"></div>
<div class="skl-bone skl-line skl-line--sm" style="width: 35%"></div>
</div>
</div>
<div class="skl-bone skl-image"></div>
<div class="skl-bone skl-line" style="width: 92%"></div>
<div class="skl-bone skl-line" style="width: 80%"></div>
<div class="skl-bone skl-line" style="width: 64%"></div>
<div class="skl-row skl-row--foot">
<div class="skl-bone skl-pill"></div>
<div class="skl-bone skl-pill" style="width: 4.5rem"></div>
</div>
</div>
<!-- Real content layer -->
<div class="skl-content" data-skl-content>
<header class="skl-head">
<span class="skl-photo" aria-hidden="true">◈</span>
<div>
<p class="skl-name">Nova Sinclair</p>
<p class="skl-meta">Design Engineer · 3 min read</p>
</div>
</header>
<div class="skl-hero" aria-hidden="true">
<span class="skl-hero-glow"></span>
</div>
<h3 class="skl-title">Designing motion that respects the user</h3>
<p class="skl-body">
Great loading states aren't an afterthought — they set the rhythm of the
whole interface. A well-tuned shimmer tells you the wait is deliberate,
the content is coming, and the product is alive.
</p>
<footer class="skl-tags" aria-label="Tags">
<span class="skl-tag">Motion</span>
<span class="skl-tag">UX</span>
</footer>
</div>
<button type="button" class="skl-reload" data-skl-reload>
<span class="skl-reload-ic" aria-hidden="true">↻</span>
Reload
</button>
</article>
</section>
<style>
.skl-stage {
min-height: 420px;
display: grid;
place-items: center;
padding: 2.5rem 1.5rem;
background:
radial-gradient(50rem 30rem at 80% -10%, color-mix(in oklab, var(--spectrum-3, #45d3e8) 12%, transparent), transparent 65%),
var(--bg-sunk, #0b0c15);
font-family: var(--font-sans, system-ui, sans-serif);
}
.skl-card {
position: relative;
width: min(30rem, 100%);
padding: 1.4rem;
border-radius: var(--radius-l, 22px);
border: 1px solid var(--border, #2a2c3a);
background: var(--surface, #16171f);
box-shadow: var(--shadow-l, 0 20px 50px rgba(0, 0, 0, 0.45));
color: var(--ink, #f2f2f7);
overflow: hidden;
}
/* Crossfade: stack skeleton + content, toggle opacity by [data-state] */
.skl-skeleton,
.skl-content {
transition: opacity var(--dur, 320ms) var(--ease-out, ease);
}
.skl-content {
display: grid;
gap: 0.9rem;
}
.skl-card[data-state="loading"] .skl-content {
opacity: 0;
/* keep it laid out (so height is stable) but non-interactive & stacked */
position: absolute;
inset: 1.4rem;
pointer-events: none;
}
.skl-card[data-state="ready"] .skl-skeleton {
opacity: 0;
position: absolute;
inset: 1.4rem;
pointer-events: none;
}
.skl-card[data-state="ready"] .skl-content { opacity: 1; }
/* --- Skeleton bones --------------------------------------------------- */
.skl-skeleton { display: grid; gap: 0.9rem; }
.skl-row { display: flex; align-items: center; gap: 0.85rem; }
.skl-row--foot { margin-top: 0.2rem; }
.skl-stack { flex: 1; display: grid; gap: 0.5rem; }
.skl-bone {
position: relative;
border-radius: 8px;
background: color-mix(in oklab, var(--ink-faint, #8a8c9c) 22%, var(--surface-2, #24252f));
overflow: hidden;
}
.skl-avatar { width: 3rem; height: 3rem; border-radius: 50%; flex: none; }
.skl-line { height: 0.85rem; }
.skl-line--sm { height: 0.7rem; }
.skl-image { height: 8rem; border-radius: var(--radius-m, 14px); }
.skl-pill { width: 5.5rem; height: 1.6rem; border-radius: var(--radius-round, 999px); }
/* shimmer sweep */
.skl-bone::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(
100deg,
transparent 20%,
color-mix(in oklab, #fff 22%, transparent) 50%,
transparent 80%
);
transform: translateX(-100%);
animation: skl-sweep 1.4s var(--ease-in-out, ease) infinite;
}
@keyframes skl-sweep { to { transform: translateX(100%); } }
/* --- Real content ----------------------------------------------------- */
.skl-head { display: flex; align-items: center; gap: 0.85rem; }
.skl-photo {
display: grid;
place-items: center;
width: 3rem;
height: 3rem;
flex: none;
border-radius: 50%;
font-size: 1.3rem;
color: #fff;
background: linear-gradient(150deg, var(--spectrum-1, #8b5cf6), var(--spectrum-3, #45d3e8));
}
.skl-name { margin: 0; font-weight: 600; color: var(--ink, #fff); }
.skl-meta { margin: 0.1rem 0 0; font-size: 0.82rem; color: var(--ink-faint, #8a8c9c); }
.skl-hero {
position: relative;
height: 8rem;
border-radius: var(--radius-m, 14px);
overflow: hidden;
background: linear-gradient(135deg,
color-mix(in oklab, var(--spectrum-1, #8b5cf6) 40%, var(--bg, #0e0f1a)),
color-mix(in oklab, var(--spectrum-3, #45d3e8) 40%, var(--bg, #0e0f1a)));
}
.skl-hero-glow {
position: absolute;
inset: -30% 40% auto -10%;
height: 120%;
background: radial-gradient(circle, color-mix(in oklab, var(--spectrum-5, #fbbf24) 60%, transparent), transparent 60%);
filter: blur(24px);
}
.skl-title { margin: 0; font-size: 1.2rem; letter-spacing: -0.01em; color: var(--ink, #fff); }
.skl-body { margin: 0; line-height: 1.55; color: var(--ink-muted, #b7b9c9); font-size: 0.92rem; }
.skl-tags { display: flex; gap: 0.5rem; }
.skl-tag {
padding: 0.3rem 0.8rem;
border-radius: var(--radius-round, 999px);
font-size: 0.78rem;
color: var(--spectrum-3, #45d3e8);
background: color-mix(in oklab, var(--spectrum-3, #45d3e8) 14%, transparent);
border: 1px solid color-mix(in oklab, var(--spectrum-3, #45d3e8) 30%, transparent);
}
/* --- Reload ----------------------------------------------------------- */
.skl-reload {
position: absolute;
top: 1rem;
right: 1rem;
z-index: 2;
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.4rem 0.75rem;
border-radius: var(--radius-round, 999px);
border: 1px solid var(--border-strong, #3a3d4d);
background: color-mix(in oklab, var(--surface-2, #24252f) 85%, transparent);
color: var(--ink, #fff);
font: inherit;
font-size: 0.8rem;
cursor: pointer;
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
transition: transform var(--dur-fast, 160ms) var(--ease-out, ease),
border-color var(--dur-fast, 160ms) var(--ease-out, ease);
}
.skl-reload:hover { transform: translateY(-1px); border-color: var(--spectrum-3, #45d3e8); }
.skl-reload-ic { display: inline-block; font-size: 0.95rem; }
.skl-card[data-state="loading"] .skl-reload-ic { animation: skl-spin 0.9s linear infinite; }
@keyframes skl-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
.skl-bone::after { animation: none; opacity: 0.4; }
.skl-reload-ic,
.skl-card[data-state="loading"] .skl-reload-ic { animation: none; }
.skl-skeleton,
.skl-content,
.skl-reload { transition: none; }
}
</style>
<script>
(function () {
function bind() {
document.querySelectorAll<HTMLElement>("[data-skl]").forEach((card) => {
if (card.dataset.bound) return;
card.dataset.bound = "1";
const reload = card.querySelector<HTMLButtonElement>("[data-skl-reload]");
const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
let timer = 0;
function ready() {
card.dataset.state = "ready";
card.setAttribute("aria-busy", "false");
}
function load() {
clearTimeout(timer);
if (reduce) { ready(); return; } // no artificial wait under reduced-motion
card.dataset.state = "loading";
card.setAttribute("aria-busy", "true");
timer = window.setTimeout(ready, 2000);
}
// Kick off the initial load once bound (markup ships in "loading").
load();
reload?.addEventListener("click", load);
});
}
document.addEventListener("astro:page-load", bind);
if (document.readyState !== "loading") bind();
})();
</script>