Article Skeleton
Original · freearticle/text skeleton
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/skel-article.json"use client";
import { useEffect, useId, useRef, useState, type KeyboardEvent } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type Variant = "article" | "feed" | "comments";
const VARIANTS: { id: Variant; label: string }[] = [
{ id: "article", label: "Article" },
{ id: "feed", label: "Feed" },
{ id: "comments", label: "Comments" },
];
const FEED: { tag: string; grad: string; title: string; snippet: string; meta: string }[] = [
{
tag: "Databases",
grad: "from-indigo-500 to-sky-500",
title: "Postgres 18 ships async I/O on by default",
snippet: "Early numbers show a 30% throughput lift on write-heavy workloads.",
meta: "acid.dev · 2h",
},
{
tag: "Policy",
grad: "from-rose-500 to-amber-500",
title: "The EU pushes the AI Act's high-risk rules to 2027",
snippet: "Regulators cite unfinished conformity-assessment tooling.",
meta: "brussels-signal · 5h",
},
{
tag: "Runtimes",
grad: "from-emerald-500 to-sky-500",
title: "Bun passes Deno in weekly npm-compat installs",
snippet: "Its Node shim now covers 96% of the top 1,000 packages.",
meta: "runtime-weekly · 9h",
},
{
tag: "Design",
grad: "from-violet-500 to-indigo-500",
title: "Figma open-sources its multiplayer CRDT core",
snippet: "The library powers cursor sync for two million daily sessions.",
meta: "figma.eng · 1d",
},
];
const COMMENTS: { initials: string; name: string; grad: string; text: string; reply: boolean }[] = [
{
initials: "DR",
name: "Devi R.",
grad: "from-emerald-500 to-sky-500",
text: "We did the exact same thing in 2023. The hardest part wasn't the code, it was convincing three teams that their service was the redundant one.",
reply: false,
},
{
initials: "MV",
name: "Marguerite (author)",
grad: "from-indigo-500 to-violet-500",
text: "That political cost is the part nobody budgets for. It took more meetings than commits.",
reply: true,
},
{
initials: "TK",
name: "Tomasz K.",
grad: "from-rose-500 to-amber-500",
text: "Curious how you handled deploy blast radius once everything shared a process. Feature flags, or something heavier?",
reply: false,
},
];
function SkelBar({ className = "" }: { className?: string }) {
return (
<div
aria-hidden="true"
className={`skelart-shimmer relative overflow-hidden rounded-md bg-slate-200 dark:bg-slate-700/70 ${className}`}
/>
);
}
export default function SkelArticle() {
const reduce = useReducedMotion();
const [variant, setVariant] = useState<Variant>("article");
const [loading, setLoading] = useState(true);
const switchId = useId();
const timerRef = useRef<number | null>(null);
const btnRefs = useRef<Record<Variant, HTMLButtonElement | null>>({
article: null,
feed: null,
comments: null,
});
useEffect(() => {
return () => {
if (timerRef.current !== null) window.clearTimeout(timerRef.current);
};
}, []);
const replay = () => {
setLoading(true);
if (timerRef.current !== null) window.clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(() => setLoading(false), 1900);
};
const onGroupKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
const len = VARIANTS.length;
const idx = VARIANTS.findIndex((v) => v.id === variant);
let next = idx;
if (e.key === "ArrowRight" || e.key === "ArrowDown") next = (idx + 1) % len;
else if (e.key === "ArrowLeft" || e.key === "ArrowUp") next = (idx - 1 + len) % len;
else if (e.key === "Home") next = 0;
else if (e.key === "End") next = len - 1;
else return;
e.preventDefault();
const nv = VARIANTS[next].id;
setVariant(nv);
btnRefs.current[nv]?.focus();
};
const label = VARIANTS.find((v) => v.id === variant)?.label ?? "Article";
const skeleton = (() => {
if (variant === "feed") {
return (
<ul className="space-y-5">
{[0, 1, 2, 3].map((i) => (
<li key={i} className="flex gap-4">
<SkelBar className="h-16 w-24 shrink-0 rounded-lg" />
<div className="flex-1 space-y-2.5 pt-1">
<SkelBar className="h-4 w-3/4" />
<SkelBar className="h-3 w-full" />
<SkelBar className="h-3 w-1/3" />
</div>
</li>
))}
</ul>
);
}
if (variant === "comments") {
return (
<div className="space-y-6">
{[false, true, false].map((reply, i) => (
<div key={i} className={`flex gap-3 ${reply ? "ml-11" : ""}`}>
<SkelBar className="h-9 w-9 shrink-0 rounded-full" />
<div className="flex-1 space-y-2.5 pt-0.5">
<SkelBar className="h-3 w-28" />
<SkelBar className="h-3 w-full" />
<SkelBar className="h-3 w-5/6" />
</div>
</div>
))}
</div>
);
}
return (
<article className="space-y-5">
<SkelBar className="h-48 w-full rounded-xl" />
<div className="flex items-center gap-3">
<SkelBar className="h-11 w-11 rounded-full" />
<div className="flex-1 space-y-2">
<SkelBar className="h-3 w-40" />
<SkelBar className="h-2.5 w-56" />
</div>
</div>
<div className="space-y-3">
<SkelBar className="h-6 w-full" />
<SkelBar className="h-6 w-4/5" />
</div>
{[
["w-full", "w-full", "w-11/12", "w-3/4"],
["w-full", "w-10/12", "w-full", "w-2/3"],
].map((lines, i) => (
<div key={i} className="space-y-2.5">
{lines.map((w, j) => (
<SkelBar key={j} className={`h-3 ${w}`} />
))}
</div>
))}
</article>
);
})();
const content = (() => {
if (variant === "feed") {
return (
<ul className="space-y-5">
{FEED.map((item) => (
<li key={item.title} className="flex gap-4">
<div
className={`grid h-16 w-24 shrink-0 place-items-center rounded-lg bg-gradient-to-br ${item.grad} text-[10px] font-semibold uppercase tracking-wide text-white/90`}
>
{item.tag}
</div>
<div className="flex-1 pt-0.5">
<h4 className="text-[15px] font-semibold leading-snug text-slate-900 dark:text-slate-100">
{item.title}
</h4>
<p className="mt-1 text-sm text-slate-600 dark:text-slate-400">{item.snippet}</p>
<p className="mt-1.5 text-xs font-medium text-slate-400 dark:text-slate-500">
{item.meta}
</p>
</div>
</li>
))}
</ul>
);
}
if (variant === "comments") {
return (
<div className="space-y-6">
{COMMENTS.map((c) => (
<div key={c.name} className={`flex gap-3 ${c.reply ? "ml-11" : ""}`}>
<div
className={`grid h-9 w-9 shrink-0 place-items-center rounded-full bg-gradient-to-br ${c.grad} text-xs font-semibold text-white`}
>
{c.initials}
</div>
<div className="flex-1">
<p className="text-sm font-semibold text-slate-900 dark:text-slate-100">{c.name}</p>
<p className="mt-1 text-sm leading-relaxed text-slate-600 dark:text-slate-300">
{c.text}
</p>
</div>
</div>
))}
</div>
);
}
return (
<article className="space-y-5">
<div className="relative h-48 w-full overflow-hidden rounded-xl bg-gradient-to-br from-indigo-500 via-violet-500 to-sky-500">
<div className="absolute inset-0 flex items-end p-5">
<span className="rounded-full bg-white/15 px-3 py-1 text-xs font-medium text-white backdrop-blur-sm">
Architecture
</span>
</div>
</div>
<div className="flex items-center gap-3">
<div className="grid h-11 w-11 place-items-center rounded-full bg-indigo-100 text-sm font-semibold text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-300">
MV
</div>
<div>
<p className="text-sm font-semibold text-slate-900 dark:text-slate-100">
Marguerite Vandenberg
</p>
<p className="text-xs text-slate-500 dark:text-slate-400">
Staff Engineer, Platform · Jul 9, 2026 · 7 min read
</p>
</div>
</div>
<h3 className="text-2xl font-bold leading-tight tracking-tight text-slate-900 dark:text-slate-50">
Why we moved forty services back to a boring monolith
</h3>
<div className="space-y-4 text-[15px] leading-relaxed text-slate-600 dark:text-slate-300">
<p>
For three years our architecture diagram looked like a subway map that had given up.
Forty-one services, nine of them owned by no one, and a Slack channel whose only job was
to announce which of them was down.
</p>
<p>
The migration back took a quarter. We collapsed the request path from six network hops to
zero, deleted roughly 30,000 lines of glue code, and cut our p99 latency in half without
rewriting a single query.
</p>
<p>
None of this means microservices are wrong. It means we reached for them to solve an
organizational problem we did not actually have, and paid for the mistake in pager duty.
</p>
</div>
</article>
);
})();
return (
<section className="relative w-full overflow-hidden bg-slate-50 py-16 dark:bg-slate-950 sm:py-24">
<style>{`
@keyframes skelart-sweep {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
.skelart-shimmer::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.55), transparent);
transform: translateX(-100%);
animation: skelart-sweep 1.6s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.skelart-shimmer::after { animation: none; opacity: 0; }
}
`}</style>
<div className="mx-auto max-w-3xl px-4">
<header className="mb-8">
<span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1 text-xs font-medium text-slate-500 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-400">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
Loading states
</span>
<h2 className="mt-4 text-3xl font-bold tracking-tight text-slate-900 dark:text-slate-50">
Content skeletons
</h2>
<p className="mt-2 max-w-xl text-[15px] leading-relaxed text-slate-600 dark:text-slate-400">
Placeholder scaffolds that hold layout while data loads. Switch the layout, then toggle
the skeleton or replay the fetch to compare the loading and loaded states.
</p>
</header>
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
<div
role="radiogroup"
aria-label="Skeleton layout"
onKeyDown={onGroupKeyDown}
className="inline-flex rounded-xl bg-slate-100 p-1 dark:bg-slate-800/80"
>
{VARIANTS.map((v) => {
const active = v.id === variant;
return (
<button
key={v.id}
ref={(el) => {
btnRefs.current[v.id] = el;
}}
type="button"
role="radio"
aria-checked={active}
tabIndex={active ? 0 : -1}
onClick={() => setVariant(v.id)}
className={`rounded-lg px-3.5 py-1.5 text-sm font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-100 dark:focus-visible:ring-offset-slate-800 ${
active
? "bg-white text-slate-900 shadow-sm dark:bg-slate-700 dark:text-white"
: "text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-200"
}`}
>
{v.label}
</button>
);
})}
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2.5">
<span
id={switchId}
className="text-sm font-medium text-slate-600 dark:text-slate-300"
>
Show skeleton
</span>
<button
type="button"
role="switch"
aria-checked={loading}
aria-labelledby={switchId}
onClick={() => setLoading((l) => !l)}
className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full outline-none transition-colors focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:focus-visible:ring-offset-slate-950 ${
loading ? "bg-indigo-600" : "bg-slate-300 dark:bg-slate-600"
}`}
>
<span
className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition-transform ${
loading ? "translate-x-[22px]" : "translate-x-0.5"
}`}
/>
</button>
</div>
<button
type="button"
onClick={replay}
aria-label="Replay the loading animation"
className="inline-flex items-center gap-2 rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-sm font-medium text-slate-700 outline-none transition-colors hover:bg-slate-50 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-950"
>
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4"
>
<path d="M21 12a9 9 0 1 1-2.64-6.36" />
<path d="M21 3v6h-6" />
</svg>
Replay
</button>
</div>
</div>
<div
role="status"
aria-live="polite"
aria-busy={loading}
className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-8"
>
<span className="sr-only">{loading ? `Loading ${label}…` : `${label} loaded`}</span>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={`${variant}-${loading ? "s" : "c"}`}
initial={reduce ? false : { opacity: 0, y: 6 }}
animate={reduce ? {} : { opacity: 1, y: 0 }}
exit={reduce ? {} : { opacity: 0, y: -6 }}
transition={{ duration: reduce ? 0 : 0.28, ease: "easeOut" }}
>
{loading ? skeleton : content}
</motion.div>
</AnimatePresence>
</div>
<p className="mt-4 text-xs text-slate-400 dark:text-slate-500">
Layout switch supports arrow keys, Home and End. The shimmer sweep pauses automatically
when reduced motion is requested.
</p>
</div>
</section>
);
}Dependencies
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Card Skeleton
Originalcard skeleton placeholder with shimmer

List Skeleton
Originallist rows skeleton

Profile Skeleton
Originalprofile header skeleton

Table Skeleton
Originaltable skeleton

Gallery Skeleton
Originalimage grid skeleton

Dashboard Skeleton
Originaldashboard widgets skeleton

Comment Skeleton
Originalcomment thread skeleton

Spotlight Hero
OriginalA centred hero with a soft radial spotlight, badge and dual call-to-action.

Split Hero
OriginalA two-column hero pairing a headline and CTAs with a product mock and social proof.

Gradient Spotlight Hero
OriginalA minimal centred hero with a soft gradient-mesh backdrop, announcement pill and dual call-to-action buttons.

App Preview Hero
OriginalA centred hero that pairs headline copy with a realistic product dashboard mock built entirely from markup, complete with browser chrome and a floating notification card.

Waitlist Capture Hero
OriginalA dark, focused hero with an inline email capture form and avatar social proof, ready for pre-launch waitlists.

