Comment Skeleton
Original · freecomment thread 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-comment.json"use client";
import {
useEffect,
useRef,
useState,
type KeyboardEvent,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type Tone = "indigo" | "violet" | "emerald" | "rose" | "amber" | "sky";
type Reply = {
id: string;
name: string;
handle: string;
time: string;
body: string;
likes: number;
tone: Tone;
};
type Comment = Reply & {
author?: boolean;
replies: Reply[];
};
const MODES = ["skeleton", "loaded"] as const;
type Mode = (typeof MODES)[number];
const MIN = 2;
const MAX = 4;
const toneAvatar: Record<Tone, string> = {
indigo: "bg-indigo-100 text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-300",
violet: "bg-violet-100 text-violet-700 dark:bg-violet-500/20 dark:text-violet-300",
emerald: "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-300",
rose: "bg-rose-100 text-rose-700 dark:bg-rose-500/20 dark:text-rose-300",
amber: "bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-300",
sky: "bg-sky-100 text-sky-700 dark:bg-sky-500/20 dark:text-sky-300",
};
const THREAD: Comment[] = [
{
id: "c1",
name: "Maya Okonkwo",
handle: "@mayabuilds",
time: "2h",
tone: "indigo",
likes: 24,
body: "We shipped almost this exact setup last quarter. The move that actually saved us was versioning tokens separately from components — designers could iterate on color and spacing without waiting on a component release.",
replies: [
{
id: "c1r1",
name: "Devon Park",
handle: "@devonp",
time: "1h",
tone: "emerald",
likes: 6,
body: "This. Decoupling the token pipeline cut our release friction roughly in half.",
},
],
},
{
id: "c2",
name: "Priya Nair",
handle: "@priyawrites",
time: "3h",
tone: "violet",
author: true,
likes: 17,
body: "Great question on dark mode — we stopped using one gray ramp and split it into a text ramp and a surface ramp. Most of our AA contrast failures on secondary text disappeared overnight.",
replies: [
{
id: "c2r1",
name: "Ilya Sorokin",
handle: "@ilya_s",
time: "2h",
tone: "amber",
likes: 4,
body: "Trying this today. Passing AA on muted body text has been our white whale for a year.",
},
],
},
{
id: "c3",
name: "Ben Carter",
handle: "@bcarter",
time: "6h",
tone: "rose",
likes: 8,
body: "Bookmarking this. The section on documenting motion tokens is the part every team skips until an animation ships that nobody can reproduce.",
replies: [],
},
{
id: "c4",
name: "Sofia Reyes",
handle: "@sofiacodes",
time: "9h",
tone: "sky",
likes: 5,
body: "Would love a follow-up on migrating legacy components without freezing feature work for a whole sprint. That trade-off is where every rollout I've seen stalls.",
replies: [],
},
];
function initials(name: string): string {
return name
.split(" ")
.map((w) => w[0])
.slice(0, 2)
.join("")
.toUpperCase();
}
function SkelBar({ className = "" }: { className?: string }) {
return (
<span
className={
"relative block overflow-hidden rounded-md bg-slate-200 dark:bg-slate-700 " +
className
}
>
<span className="skelcm-shine" />
</span>
);
}
function SkeletonRow({ reply = false }: { reply?: boolean }) {
return (
<div className="flex gap-3">
<SkelBar
className={
reply ? "h-8 w-8 shrink-0 rounded-full" : "h-10 w-10 shrink-0 rounded-full"
}
/>
<div className="min-w-0 flex-1 space-y-2.5 pt-1">
<div className="flex items-center gap-2">
<SkelBar className={reply ? "h-3 w-20" : "h-3.5 w-28"} />
<SkelBar className="h-3 w-14" />
</div>
<div className="space-y-2">
<SkelBar className="h-2.5 w-full" />
<SkelBar className={reply ? "h-2.5 w-3/5" : "h-2.5 w-11/12"} />
{!reply && <SkelBar className="h-2.5 w-2/3" />}
</div>
<div className="flex items-center gap-4 pt-0.5">
<SkelBar className="h-3 w-10" />
<SkelBar className="h-3 w-12" />
</div>
</div>
</div>
);
}
function HeartIcon({ filled }: { filled: boolean }) {
return (
<svg
viewBox="0 0 24 24"
fill={filled ? "currentColor" : "none"}
stroke="currentColor"
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4"
aria-hidden="true"
>
<path d="M12 20.5 4.55 13.05a4.5 4.5 0 0 1 6.36-6.36l1.09 1.09 1.09-1.09a4.5 4.5 0 1 1 6.36 6.36L12 20.5Z" />
</svg>
);
}
function ReplyIcon() {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4"
aria-hidden="true"
>
<path d="M9 14 4 9l5-5" />
<path d="M4 9h9a7 7 0 0 1 7 7v3" />
</svg>
);
}
export default function SkelComment() {
const reduce = useReducedMotion();
const [mode, setMode] = useState<Mode>("skeleton");
const [count, setCount] = useState(3);
const [showReplies, setShowReplies] = useState(true);
const [reloadKey, setReloadKey] = useState(0);
const [liked, setLiked] = useState<Record<string, boolean>>({});
const [openReply, setOpenReply] = useState<string | null>(null);
const [draft, setDraft] = useState("");
const [extraReplies, setExtraReplies] = useState<Record<string, string[]>>({});
const timerRef = useRef<number | null>(null);
const segRefs = useRef<(HTMLButtonElement | null)[]>([]);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const visible = THREAD.slice(0, count);
const clearTimer = () => {
if (timerRef.current !== null) {
window.clearTimeout(timerRef.current);
timerRef.current = null;
}
};
useEffect(() => clearTimer, []);
useEffect(() => {
if (openReply) textareaRef.current?.focus();
}, [openReply]);
const selectMode = (m: Mode) => {
clearTimer();
setMode(m);
};
const simulateLoad = () => {
clearTimer();
setReloadKey((k) => k + 1);
setMode("skeleton");
timerRef.current = window.setTimeout(() => {
setMode("loaded");
timerRef.current = null;
}, 1600);
};
const onSegKeyDown = (e: KeyboardEvent<HTMLButtonElement>, idx: number) => {
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
e.preventDefault();
const n = (idx + 1) % MODES.length;
selectMode(MODES[n]);
segRefs.current[n]?.focus();
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
e.preventDefault();
const n = (idx - 1 + MODES.length) % MODES.length;
selectMode(MODES[n]);
segRefs.current[n]?.focus();
}
};
const postReply = (id: string) => {
const t = draft.trim();
if (!t) return;
setExtraReplies((p) => ({ ...p, [id]: [...(p[id] ?? []), t] }));
setDraft("");
setOpenReply(null);
};
const ringBtn =
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900";
const transition = { duration: reduce ? 0 : 0.28, ease: "easeOut" as const };
return (
<section className="relative w-full bg-slate-50 py-16 dark:bg-slate-950 sm:py-20">
<style>{`
@keyframes skelcm-sweep {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
.skelcm-shine {
position: absolute;
inset: 0;
transform: translateX(-100%);
background: linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.5) 50%, transparent 100%);
animation: skelcm-sweep 1.6s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.skelcm-shine { animation: none; }
}
`}</style>
<div className="mx-auto w-full max-w-2xl px-4 sm:px-6">
<div className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
{/* Header */}
<div className="border-b border-slate-200 px-5 py-4 dark:border-slate-800 sm:px-6">
<div className="flex items-center gap-2.5">
<span className="inline-flex h-9 w-9 items-center justify-center rounded-full bg-indigo-100 text-indigo-600 dark:bg-indigo-500/20 dark:text-indigo-300">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
className="h-5 w-5"
aria-hidden="true"
>
<path d="M21 11.5a8.38 8.38 0 0 1-8.5 8.5 9 9 0 0 1-4-.9L3 21l1.9-5.5a8.38 8.38 0 0 1-.9-4A8.5 8.5 0 0 1 12.5 3 8.38 8.38 0 0 1 21 11.5Z" />
</svg>
</span>
<div className="min-w-0">
<h2 className="text-base font-semibold text-slate-900 dark:text-slate-100">
Discussion
<span className="ml-2 rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-500 dark:bg-slate-800 dark:text-slate-400">
{visible.length}
</span>
</h2>
<p className="truncate text-sm text-slate-500 dark:text-slate-400">
A live preview of the loading state vs. the resolved thread.
</p>
</div>
</div>
{/* Toolbar */}
<div className="mt-4 flex flex-wrap items-center gap-3">
{/* Segmented mode control */}
<div
role="radiogroup"
aria-label="Thread state"
className="inline-flex rounded-lg border border-slate-200 bg-slate-100 p-0.5 dark:border-slate-700 dark:bg-slate-800"
>
{MODES.map((m, i) => {
const active = mode === m;
return (
<button
key={m}
ref={(el) => {
segRefs.current[i] = el;
}}
type="button"
role="radio"
aria-checked={active}
tabIndex={active ? 0 : -1}
onKeyDown={(e) => onSegKeyDown(e, i)}
onClick={() => selectMode(m)}
className={
"rounded-[7px] px-3 py-1.5 text-sm font-medium capitalize transition motion-reduce:transition-none " +
ringBtn +
(active
? " bg-white text-slate-900 shadow-sm dark:bg-slate-950 dark:text-slate-100"
: " text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-200")
}
>
{m}
</button>
);
})}
</div>
{/* Count stepper */}
<div
className="inline-flex items-center gap-1 rounded-lg border border-slate-200 bg-white p-0.5 dark:border-slate-700 dark:bg-slate-900"
role="group"
aria-label="Number of comments"
>
<button
type="button"
aria-label="Fewer comments"
disabled={count <= MIN}
onClick={() => setCount((c) => Math.max(MIN, c - 1))}
className={
"inline-flex h-7 w-7 items-center justify-center rounded-md text-slate-600 transition hover:bg-slate-100 disabled:pointer-events-none disabled:opacity-40 dark:text-slate-300 dark:hover:bg-slate-800 motion-reduce:transition-none " +
ringBtn
}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
className="h-4 w-4"
aria-hidden="true"
>
<path d="M5 12h14" />
</svg>
</button>
<span
aria-live="polite"
aria-label={`${count} comments`}
className="w-5 text-center text-sm font-semibold tabular-nums text-slate-800 dark:text-slate-100"
>
{count}
</span>
<button
type="button"
aria-label="More comments"
disabled={count >= MAX}
onClick={() => setCount((c) => Math.min(MAX, c + 1))}
className={
"inline-flex h-7 w-7 items-center justify-center rounded-md text-slate-600 transition hover:bg-slate-100 disabled:pointer-events-none disabled:opacity-40 dark:text-slate-300 dark:hover:bg-slate-800 motion-reduce:transition-none " +
ringBtn
}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
className="h-4 w-4"
aria-hidden="true"
>
<path d="M12 5v14M5 12h14" />
</svg>
</button>
</div>
{/* Replies switch */}
<div className="inline-flex items-center gap-2">
<span
id="skelcm-replies-label"
className="text-sm font-medium text-slate-600 dark:text-slate-300"
>
Replies
</span>
<button
type="button"
role="switch"
aria-checked={showReplies}
aria-labelledby="skelcm-replies-label"
onClick={() => setShowReplies((v) => !v)}
className={
"relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors motion-reduce:transition-none " +
ringBtn +
(showReplies
? " bg-indigo-600 dark:bg-indigo-500"
: " bg-slate-300 dark:bg-slate-700")
}
>
<span
className={
"inline-block h-[18px] w-[18px] transform rounded-full bg-white shadow-sm transition-transform motion-reduce:transition-none " +
(showReplies ? "translate-x-6" : "translate-x-1")
}
/>
</button>
</div>
{/* Reload */}
<button
type="button"
onClick={simulateLoad}
className={
"ml-auto inline-flex items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-sm font-medium text-slate-700 transition hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800 motion-reduce:transition-none " +
ringBtn
}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4"
aria-hidden="true"
>
<path d="M21 12a9 9 0 1 1-2.64-6.36" />
<path d="M21 3v6h-6" />
</svg>
Reload
</button>
</div>
</div>
{/* Live status for assistive tech */}
<p className="sr-only" role="status" aria-live="polite">
{mode === "skeleton"
? "Loading comments"
: `${visible.length} comments loaded`}
</p>
{/* Thread body */}
<div
aria-busy={mode === "skeleton"}
className="px-5 py-5 sm:px-6 sm:py-6"
>
<AnimatePresence mode="wait" initial={false}>
{mode === "skeleton" ? (
<motion.div
key={`skeleton-${reloadKey}`}
aria-hidden="true"
initial={{ opacity: 0, y: reduce ? 0 : 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: reduce ? 0 : -6 }}
transition={transition}
className="space-y-7"
>
{Array.from({ length: count }).map((_, i) => (
<div key={i} className="space-y-4">
<SkeletonRow />
{showReplies && i % 2 === 0 && (
<div className="ml-5 border-l border-slate-200 pl-4 dark:border-slate-800">
<SkeletonRow reply />
</div>
)}
</div>
))}
</motion.div>
) : (
<motion.ul
key="loaded"
initial={{ opacity: 0, y: reduce ? 0 : 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: reduce ? 0 : -6 }}
transition={transition}
className="space-y-7"
>
{visible.map((c) => {
const isLiked = liked[c.id] ?? false;
const extra = extraReplies[c.id] ?? [];
return (
<li key={c.id} className="space-y-4">
<article className="flex gap-3">
<span
className={
"inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-sm font-semibold " +
toneAvatar[c.tone]
}
aria-hidden="true"
>
{initials(c.name)}
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
<span className="text-sm font-semibold text-slate-900 dark:text-slate-100">
{c.name}
</span>
{c.author && (
<span className="rounded-full bg-indigo-100 px-1.5 py-0.5 text-[11px] font-medium text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-300">
Author
</span>
)}
<span className="text-xs text-slate-400 dark:text-slate-500">
{c.handle} · {c.time}
</span>
</div>
<p className="mt-1 text-sm leading-relaxed text-slate-600 dark:text-slate-300">
{c.body}
</p>
<div className="mt-2 flex items-center gap-4">
<button
type="button"
aria-pressed={isLiked}
aria-label={`Like comment by ${c.name}`}
onClick={() =>
setLiked((p) => ({ ...p, [c.id]: !isLiked }))
}
className={
"inline-flex items-center gap-1.5 rounded-md px-1.5 py-1 text-xs font-medium transition active:scale-95 motion-reduce:transition-none motion-reduce:active:scale-100 " +
ringBtn +
(isLiked
? " text-rose-600 dark:text-rose-400"
: " text-slate-500 hover:text-rose-600 dark:text-slate-400 dark:hover:text-rose-400")
}
>
<HeartIcon filled={isLiked} />
<span className="tabular-nums">
{c.likes + (isLiked ? 1 : 0)}
</span>
</button>
<button
type="button"
aria-expanded={openReply === c.id}
aria-label={`Reply to ${c.name}`}
onClick={() => {
setOpenReply((o) => (o === c.id ? null : c.id));
setDraft("");
}}
className={
"inline-flex items-center gap-1.5 rounded-md px-1.5 py-1 text-xs font-medium text-slate-500 transition hover:text-indigo-600 dark:text-slate-400 dark:hover:text-indigo-400 motion-reduce:transition-none " +
ringBtn
}
>
<ReplyIcon />
Reply
</button>
</div>
{openReply === c.id && (
<div className="mt-3">
<label htmlFor={`skelcm-draft-${c.id}`} className="sr-only">
Write a reply to {c.name}
</label>
<textarea
id={`skelcm-draft-${c.id}`}
ref={textareaRef}
value={draft}
onChange={(e) => setDraft(e.target.value)}
rows={2}
placeholder={`Reply to ${c.name.split(" ")[0]}…`}
className={
"w-full resize-none rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-100 dark:placeholder:text-slate-500 " +
ringBtn
}
/>
<div className="mt-2 flex items-center justify-end gap-2">
<button
type="button"
onClick={() => {
setOpenReply(null);
setDraft("");
}}
className={
"rounded-lg px-3 py-1.5 text-sm font-medium text-slate-600 transition hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800 motion-reduce:transition-none " +
ringBtn
}
>
Cancel
</button>
<button
type="button"
disabled={draft.trim().length === 0}
onClick={() => postReply(c.id)}
className={
"rounded-lg bg-indigo-600 px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-indigo-500 disabled:pointer-events-none disabled:opacity-40 dark:bg-indigo-500 dark:hover:bg-indigo-400 motion-reduce:transition-none " +
ringBtn
}
>
Post reply
</button>
</div>
</div>
)}
{/* Nested replies */}
{showReplies && (c.replies.length > 0 || extra.length > 0) && (
<div className="mt-4 space-y-4 border-l border-slate-200 pl-4 dark:border-slate-800">
{c.replies.map((r) => {
const rLiked = liked[r.id] ?? false;
return (
<article key={r.id} className="flex gap-3">
<span
className={
"inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-semibold " +
toneAvatar[r.tone]
}
aria-hidden="true"
>
{initials(r.name)}
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2">
<span className="text-sm font-semibold text-slate-900 dark:text-slate-100">
{r.name}
</span>
<span className="text-xs text-slate-400 dark:text-slate-500">
{r.handle} · {r.time}
</span>
</div>
<p className="mt-1 text-sm leading-relaxed text-slate-600 dark:text-slate-300">
{r.body}
</p>
<button
type="button"
aria-pressed={rLiked}
aria-label={`Like reply by ${r.name}`}
onClick={() =>
setLiked((p) => ({ ...p, [r.id]: !rLiked }))
}
className={
"mt-1.5 inline-flex items-center gap-1.5 rounded-md px-1.5 py-1 text-xs font-medium transition active:scale-95 motion-reduce:transition-none motion-reduce:active:scale-100 " +
ringBtn +
(rLiked
? " text-rose-600 dark:text-rose-400"
: " text-slate-500 hover:text-rose-600 dark:text-slate-400 dark:hover:text-rose-400")
}
>
<HeartIcon filled={rLiked} />
<span className="tabular-nums">
{r.likes + (rLiked ? 1 : 0)}
</span>
</button>
</div>
</article>
);
})}
{extra.map((text, i) => (
<article key={`x-${i}`} className="flex gap-3">
<span
className={
"inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-semibold " +
toneAvatar.sky
}
aria-hidden="true"
>
YOU
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2">
<span className="text-sm font-semibold text-slate-900 dark:text-slate-100">
You
</span>
<span className="text-xs text-slate-400 dark:text-slate-500">
just now
</span>
</div>
<p className="mt-1 whitespace-pre-wrap break-words text-sm leading-relaxed text-slate-600 dark:text-slate-300">
{text}
</p>
</div>
</article>
))}
</div>
)}
</div>
</article>
</li>
);
})}
</motion.ul>
)}
</AnimatePresence>
</div>
</div>
</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

Article Skeleton
Originalarticle/text skeleton

Table Skeleton
Originaltable skeleton

Gallery Skeleton
Originalimage grid skeleton

Dashboard Skeleton
Originaldashboard widgets 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.

