List Skeleton
Original · freelist rows 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-list.json"use client"
import { useEffect, useId, useRef, useState, type KeyboardEvent } from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
type Item = {
id: string
initials: string
tone: string
title: string
repo: string
author: string
time: string
status: string
statusTone: string
}
const QUEUE: Item[] = [
{ id: "PR-4821", initials: "RM", tone: "indigo", title: "Cache invalidation on stale review comments", repo: "core/api", author: "Riya Malhotra", time: "12m", status: "Needs review", statusTone: "amber" },
{ id: "PR-4818", initials: "TN", tone: "emerald", title: "Add retry budget to webhook dispatcher", repo: "platform/events", author: "Tomas Novak", time: "34m", status: "Approved", statusTone: "emerald" },
{ id: "PR-4815", initials: "AO", tone: "rose", title: "Fix off-by-one in pagination cursor", repo: "core/api", author: "Ada Okonkwo", time: "1h", status: "Changes requested", statusTone: "rose" },
{ id: "PR-4809", initials: "LK", tone: "sky", title: "Migrate auth session store to Redis", repo: "infra/auth", author: "Lena Kraus", time: "2h", status: "In CI", statusTone: "sky" },
{ id: "PR-4802", initials: "DM", tone: "violet", title: "Refactor billing invoice PDF renderer", repo: "billing/web", author: "Diego Marín", time: "3h", status: "Needs review", statusTone: "amber" },
{ id: "PR-4796", initials: "SP", tone: "amber", title: "Introduce feature flags for dashboard v2", repo: "apps/dashboard", author: "Sofia Petrova", time: "5h", status: "Draft", statusTone: "slate" },
{ id: "PR-4790", initials: "HK", tone: "emerald", title: "Tighten rate limits on public search API", repo: "core/api", author: "Hassan Karim", time: "6h", status: "Approved", statusTone: "emerald" },
{ id: "PR-4781", initials: "MW", tone: "slate", title: "Document SSO onboarding for enterprise", repo: "docs/site", author: "Mei Wong", time: "8h", status: "Needs review", statusTone: "amber" },
]
const AVATAR_TONE: Record<string, string> = {
indigo: "bg-indigo-100 text-indigo-700 dark:bg-indigo-500/15 dark:text-indigo-300",
violet: "bg-violet-100 text-violet-700 dark:bg-violet-500/15 dark:text-violet-300",
emerald: "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300",
sky: "bg-sky-100 text-sky-700 dark:bg-sky-500/15 dark:text-sky-300",
amber: "bg-amber-100 text-amber-700 dark:bg-amber-500/15 dark:text-amber-300",
rose: "bg-rose-100 text-rose-700 dark:bg-rose-500/15 dark:text-rose-300",
slate: "bg-slate-200 text-slate-700 dark:bg-slate-700 dark:text-slate-200",
}
const STATUS_TONE: Record<string, string> = {
amber: "bg-amber-50 text-amber-700 ring-amber-600/20 dark:bg-amber-500/10 dark:text-amber-300 dark:ring-amber-400/25",
emerald: "bg-emerald-50 text-emerald-700 ring-emerald-600/20 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-400/25",
rose: "bg-rose-50 text-rose-700 ring-rose-600/20 dark:bg-rose-500/10 dark:text-rose-300 dark:ring-rose-400/25",
sky: "bg-sky-50 text-sky-700 ring-sky-600/20 dark:bg-sky-500/10 dark:text-sky-300 dark:ring-sky-400/25",
slate: "bg-slate-100 text-slate-600 ring-slate-500/20 dark:bg-slate-700/40 dark:text-slate-300 dark:ring-slate-400/20",
}
const TITLE_WIDTHS = ["w-4/5", "w-3/5", "w-11/12", "w-2/3", "w-3/4", "w-5/6", "w-1/2", "w-4/6"]
const META_WIDTHS = ["w-2/5", "w-1/3", "w-2/6", "w-1/4"]
const MIN_ROWS = 3
const MAX_ROWS = 8
const focusRing =
"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"
function Shimmer({ reduced, className }: { reduced: boolean; className: string }) {
return (
<span
aria-hidden="true"
className={`block bg-slate-200/90 dark:bg-slate-700/60 ${reduced ? "" : "skel-list-bar"} ${className}`}
/>
)
}
function Toggle({
checked,
onChange,
label,
}: {
checked: boolean
onChange: (v: boolean) => void
label: string
}) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={`inline-flex items-center gap-2 rounded-full ${focusRing}`}
>
<span
className={`relative h-5 w-9 shrink-0 rounded-full transition-colors ${
checked ? "bg-indigo-600 dark:bg-indigo-500" : "bg-slate-300 dark:bg-slate-600"
}`}
>
<span
className={`absolute left-0.5 top-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${
checked ? "translate-x-4" : "translate-x-0"
}`}
/>
</span>
<span className="text-xs font-medium text-slate-600 dark:text-slate-300">{label}</span>
</button>
)
}
function Segmented({
label,
value,
options,
onChange,
}: {
label: string
value: string
options: { value: string; label: string }[]
onChange: (v: string) => void
}) {
const refs = useRef<(HTMLButtonElement | null)[]>([])
const idx = options.findIndex((o) => o.value === value)
function move(next: number) {
const n = (next + options.length) % options.length
onChange(options[n].value)
refs.current[n]?.focus()
}
function onKeyDown(e: KeyboardEvent<HTMLButtonElement>) {
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
e.preventDefault()
move(idx + 1)
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
e.preventDefault()
move(idx - 1)
} else if (e.key === "Home") {
e.preventDefault()
move(0)
} else if (e.key === "End") {
e.preventDefault()
move(options.length - 1)
}
}
return (
<div
role="radiogroup"
aria-label={label}
className="inline-flex rounded-lg border border-slate-200 bg-white p-0.5 dark:border-slate-700 dark:bg-slate-900"
>
{options.map((o, i) => {
const active = o.value === value
return (
<button
key={o.value}
ref={(el) => {
refs.current[i] = el
}}
type="button"
role="radio"
aria-checked={active}
tabIndex={active ? 0 : -1}
onClick={() => onChange(o.value)}
onKeyDown={onKeyDown}
className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${focusRing} ${
active
? "bg-slate-900 text-white shadow-sm dark:bg-white dark:text-slate-900"
: "text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-200"
}`}
>
{o.label}
</button>
)
})}
</div>
)
}
function RefreshIcon({ spinning }: { spinning: boolean }) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className={`h-3.5 w-3.5 ${spinning ? "skel-list-spin" : ""}`}
>
<path d="M3 12a9 9 0 0 1 15-6.7L21 8" />
<path d="M21 3v5h-5" />
<path d="M21 12a9 9 0 0 1-15 6.7L3 16" />
<path d="M3 21v-5h5" />
</svg>
)
}
export default function SkelList() {
const prefersReduced = useReducedMotion()
const reduced = !!prefersReduced
const [loading, setLoading] = useState(true)
const [rowCount, setRowCount] = useState(5)
const [density, setDensity] = useState<"cozy" | "compact">("cozy")
const [avatars, setAvatars] = useState(true)
const headingId = useId()
const timer = useRef<number | null>(null)
useEffect(() => {
return () => {
if (timer.current) window.clearTimeout(timer.current)
}
}, [])
function reload() {
if (timer.current) window.clearTimeout(timer.current)
setLoading(true)
timer.current = window.setTimeout(() => setLoading(false), 1400)
}
const rowPad = density === "cozy" ? "py-4" : "py-2.5"
const avatarSize = density === "cozy" ? "h-11 w-11" : "h-9 w-9"
const rows = QUEUE.slice(0, rowCount)
return (
<section className="relative w-full bg-slate-50 px-4 py-16 dark:bg-slate-950 sm:px-6 sm:py-20 lg:px-8">
<style>{`
@keyframes skel-list-sweep { 0% { transform: translateX(-100%); } 100% { transform: translateX(100%); } }
@keyframes skel-list-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } }
@keyframes skel-list-spin { to { transform: rotate(360deg); } }
.skel-list-bar { position: relative; overflow: hidden; }
.skel-list-bar::after {
content: "";
position: absolute;
inset: 0;
transform: translateX(-100%);
background: linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.65) 50%, transparent 100%);
animation: skel-list-sweep 1.6s ease-in-out infinite;
}
.skel-list-dot { animation: skel-list-pulse 1.2s ease-in-out infinite; }
.skel-list-spin { animation: skel-list-spin 0.9s linear infinite; }
@media (prefers-color-scheme: dark) {
.skel-list-bar::after { background: linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.12) 50%, transparent 100%); }
}
@media (prefers-reduced-motion: reduce) {
.skel-list-bar::after { animation: none; }
.skel-list-dot, .skel-list-spin { animation: none; }
}
`}</style>
<div className="mx-auto w-full max-w-3xl">
<div className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="p-5 sm:p-7">
{/* Header */}
<div className="mb-4 flex items-start justify-between gap-4">
<div>
<h2 id={headingId} className="text-lg font-semibold tracking-tight text-slate-900 dark:text-white">
Review queue
</h2>
<p className="mt-0.5 text-sm text-slate-500 dark:text-slate-400">
Pull requests waiting on your team
</p>
</div>
<span className="inline-flex shrink-0 items-center gap-1.5 rounded-full bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-600 dark:bg-slate-800 dark:text-slate-300">
{loading ? (
<>
<span className="skel-list-dot h-1.5 w-1.5 rounded-full bg-amber-500" />
Loading
</>
) : (
<>
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
Live
</>
)}
</span>
</div>
{/* Controls */}
<div className="mb-5 flex flex-wrap items-center gap-x-5 gap-y-3 rounded-xl border border-slate-200 bg-slate-50/80 px-4 py-3 dark:border-slate-800 dark:bg-slate-800/40">
<Toggle checked={loading} onChange={setLoading} label="Skeleton" />
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-slate-500 dark:text-slate-400">Rows</span>
<div
role="group"
aria-label="Number of rows"
className="flex items-center gap-1 rounded-lg border border-slate-200 bg-white p-0.5 dark:border-slate-700 dark:bg-slate-900"
>
<button
type="button"
aria-label="Show fewer rows"
disabled={rowCount <= MIN_ROWS}
onClick={() => setRowCount((c) => Math.max(MIN_ROWS, c - 1))}
className={`grid h-7 w-7 place-items-center rounded-md text-slate-600 transition-colors hover:bg-slate-100 disabled:opacity-40 disabled:hover:bg-transparent dark:text-slate-300 dark:hover:bg-slate-800 ${focusRing}`}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" aria-hidden="true" className="h-3.5 w-3.5">
<path d="M5 12h14" />
</svg>
</button>
<span aria-live="polite" className="w-5 text-center text-sm font-semibold tabular-nums text-slate-700 dark:text-slate-200">
{rowCount}
</span>
<button
type="button"
aria-label="Show more rows"
disabled={rowCount >= MAX_ROWS}
onClick={() => setRowCount((c) => Math.min(MAX_ROWS, c + 1))}
className={`grid h-7 w-7 place-items-center rounded-md text-slate-600 transition-colors hover:bg-slate-100 disabled:opacity-40 disabled:hover:bg-transparent dark:text-slate-300 dark:hover:bg-slate-800 ${focusRing}`}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" aria-hidden="true" className="h-3.5 w-3.5">
<path d="M12 5v14M5 12h14" />
</svg>
</button>
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-slate-500 dark:text-slate-400">Density</span>
<Segmented
label="Row density"
value={density}
onChange={(v) => setDensity(v as "cozy" | "compact")}
options={[
{ value: "cozy", label: "Cozy" },
{ value: "compact", label: "Compact" },
]}
/>
</div>
<Toggle checked={avatars} onChange={setAvatars} label="Avatars" />
<button
type="button"
onClick={reload}
className={`ml-auto inline-flex items-center gap-1.5 rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-semibold text-white shadow-sm transition-colors hover:bg-indigo-500 ${focusRing}`}
>
<RefreshIcon spinning={loading && !reduced} />
Reload
</button>
</div>
{/* List */}
<div className="rounded-xl border border-slate-200 dark:border-slate-800">
<AnimatePresence mode="wait" initial={false}>
{loading ? (
<motion.ul
key="skeleton"
role="status"
aria-busy="true"
aria-live="polite"
aria-label="Loading review queue"
className="divide-y divide-slate-100 px-4 dark:divide-slate-800"
initial={reduced ? false : { opacity: 0 }}
animate={reduced ? {} : { opacity: 1 }}
exit={reduced ? {} : { opacity: 0 }}
transition={{ duration: 0.15 }}
>
<li className="sr-only">Loading review queue, please wait…</li>
{Array.from({ length: rowCount }).map((_, i) => (
<li key={i} className={`flex items-center gap-4 ${rowPad}`}>
{avatars && <Shimmer reduced={reduced} className={`${avatarSize} shrink-0 rounded-full`} />}
<div className="min-w-0 flex-1">
<Shimmer reduced={reduced} className={`h-3.5 rounded ${TITLE_WIDTHS[i % TITLE_WIDTHS.length]}`} />
<Shimmer reduced={reduced} className={`mt-2.5 h-3 rounded ${META_WIDTHS[i % META_WIDTHS.length]}`} />
</div>
<Shimmer reduced={reduced} className="h-6 w-20 shrink-0 rounded-full" />
</li>
))}
</motion.ul>
) : (
<motion.ul
key="content"
role="list"
className="divide-y divide-slate-100 px-4 dark:divide-slate-800"
initial={reduced ? false : { opacity: 0 }}
animate={reduced ? {} : { opacity: 1 }}
exit={reduced ? {} : { opacity: 0 }}
transition={{ duration: 0.15 }}
>
{rows.map((item, i) => (
<motion.li
key={item.id}
className={`flex items-center gap-4 ${rowPad}`}
initial={reduced ? false : { opacity: 0, y: 6 }}
animate={reduced ? {} : { opacity: 1, y: 0 }}
transition={reduced ? undefined : { duration: 0.25, delay: i * 0.04 }}
>
{avatars && (
<span
className={`grid ${avatarSize} shrink-0 place-items-center rounded-full text-xs font-semibold ${AVATAR_TONE[item.tone]}`}
>
{item.initials}
</span>
)}
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-slate-900 dark:text-slate-100">
{item.title}
</p>
<p className="mt-1 truncate text-xs text-slate-500 dark:text-slate-400">
<span className="font-mono">{item.repo}</span> · {item.author} · {item.time}
</p>
</div>
<span
className={`shrink-0 whitespace-nowrap rounded-full px-2.5 py-1 text-xs font-medium ring-1 ring-inset ${STATUS_TONE[item.statusTone]}`}
>
{item.status}
</span>
</motion.li>
))}
</motion.ul>
)}
</AnimatePresence>
</div>
<p className="mt-4 text-xs text-slate-400 dark:text-slate-500">
{loading
? "Fetching the latest activity…"
: `Showing ${rowCount} of ${QUEUE.length} open pull requests`}
</p>
</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

Profile Skeleton
Originalprofile header skeleton

Article Skeleton
Originalarticle/text 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.

