Web InnoventixFreeCode

List Skeleton

Original · free

list rows skeleton

byWeb InnoventixReact + Tailwind
skellistskeletons
Open

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
skel-list.tsx
"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

motion

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 quote

Similar components

Browse all →