Web InnoventixFreeCode

Article Skeleton

Original · free

article/text skeleton

byWeb InnoventixReact + Tailwind
skelarticleskeletons
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-article.json
skel-article.tsx
"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

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 →