Web InnoventixFreeCode

Card Skeleton

Original · free

card skeleton placeholder with shimmer

byWeb InnoventixReact + Tailwind
skelcardskeletons
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-card.json
skel-card.tsx
"use client";

import { useEffect, useRef, useState } from "react";
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
import { motion, useReducedMotion } from "motion/react";

type SkelStyle = "shimmer" | "pulse" | "wave";

type Item = {
  id: number;
  tag: string;
  title: string;
  excerpt: string;
  author: string;
  initials: string;
  read: string;
  cover: string;
  avatar: string;
};

const ITEMS: Item[] = [
  {
    id: 1,
    tag: "Design systems",
    title: "Tokens that survive a full rebrand",
    excerpt:
      "We renamed every color twice and shipped zero visual regressions. Here is the naming layer that made the migration boring on purpose.",
    author: "Priya Nair",
    initials: "PN",
    read: "8 min read",
    cover: "from-indigo-500 to-violet-600",
    avatar: "from-indigo-400 to-violet-500",
  },
  {
    id: 2,
    tag: "Motion",
    title: "Easing curves people can actually feel",
    excerpt:
      "Linear is a lie your users notice. A short tour of the four curves we reach for and exactly when each one earns its place.",
    author: "Diego Ruiz",
    initials: "DR",
    read: "6 min read",
    cover: "from-emerald-500 to-sky-600",
    avatar: "from-emerald-400 to-sky-500",
  },
  {
    id: 3,
    tag: "Research",
    title: "Usability tests without a lab or a budget",
    excerpt:
      "Five unmoderated sessions beat one perfect study. How we run them in an afternoon using tools the team already pays for.",
    author: "Amara Okafor",
    initials: "AO",
    read: "5 min read",
    cover: "from-rose-500 to-amber-500",
    avatar: "from-rose-400 to-amber-400",
  },
  {
    id: 4,
    tag: "Accessibility",
    title: "Focus rings are a feature, not a bug",
    excerpt:
      "The outline you keep deleting is the map keyboard users navigate by. Here is how to make it look intentional instead of accidental.",
    author: "Lena Fischer",
    initials: "LF",
    read: "7 min read",
    cover: "from-sky-500 to-indigo-600",
    avatar: "from-sky-400 to-indigo-500",
  },
  {
    id: 5,
    tag: "Front-end",
    title: "Skeletons that lower bounce, not just spinners",
    excerpt:
      "Perceived performance is still performance. What changed for us when we swapped generic loaders for content-shaped placeholders.",
    author: "Sam Whitfield",
    initials: "SW",
    read: "9 min read",
    cover: "from-violet-500 to-rose-500",
    avatar: "from-violet-400 to-rose-400",
  },
  {
    id: 6,
    tag: "Process",
    title: "Design reviews that end in decisions",
    excerpt:
      "Meetings drift when nobody owns the call. The one-page agenda that keeps critique honest, kind, and genuinely short.",
    author: "Yuki Tanaka",
    initials: "YT",
    read: "4 min read",
    cover: "from-amber-500 to-emerald-500",
    avatar: "from-amber-400 to-emerald-400",
  },
];

const STYLES: { id: SkelStyle; label: string }[] = [
  { id: "shimmer", label: "Shimmer" },
  { id: "pulse", label: "Pulse" },
  { id: "wave", label: "Wave" },
];

const MIN_CARDS = 2;
const MAX_CARDS = 6;
const LOAD_MS = 2200;

const RING =
  "focus-visible:outline-none 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";

function Skel({
  className,
  skelStyle,
  reduced,
}: {
  className?: string;
  skelStyle: SkelStyle;
  reduced: boolean;
}) {
  const anim = reduced
    ? ""
    : skelStyle === "pulse"
      ? " skelcard-pulse"
      : skelStyle === "wave"
        ? " skelcard-wave"
        : "";
  return (
    <span
      aria-hidden="true"
      className={`relative block overflow-hidden bg-slate-200 dark:bg-slate-800${anim}${
        className ? " " + className : ""
      }`}
    >
      {!reduced && skelStyle === "shimmer" ? (
        <span className="skelcard-sweep pointer-events-none absolute inset-0 -translate-x-full bg-gradient-to-r from-transparent via-white/70 to-transparent dark:via-white/10" />
      ) : null}
    </span>
  );
}

export default function SkelCard() {
  const reduced = useReducedMotion() ?? false;

  const [cycle, setCycle] = useState(0);
  const [loading, setLoading] = useState(true);
  const [hold, setHold] = useState(false);
  const [skelStyle, setSkelStyle] = useState<SkelStyle>("shimmer");
  const [count, setCount] = useState(3);
  const [saved, setSaved] = useState<Record<number, boolean>>({});

  const styleRefs = useRef<Array<HTMLButtonElement | null>>([]);

  useEffect(() => {
    setLoading(true);
    const t = window.setTimeout(() => setLoading(false), LOAD_MS);
    return () => window.clearTimeout(t);
  }, [cycle]);

  const showSkeleton = hold || loading;
  const visible = ITEMS.slice(0, count);

  const onStyleKey = (
    e: ReactKeyboardEvent<HTMLButtonElement>,
    idx: number,
  ) => {
    let next = idx;
    if (e.key === "ArrowRight" || e.key === "ArrowDown") next = (idx + 1) % STYLES.length;
    else if (e.key === "ArrowLeft" || e.key === "ArrowUp")
      next = (idx - 1 + STYLES.length) % STYLES.length;
    else if (e.key === "Home") next = 0;
    else if (e.key === "End") next = STYLES.length - 1;
    else return;
    e.preventDefault();
    setSkelStyle(STYLES[next].id);
    styleRefs.current[next]?.focus();
  };

  return (
    <section className="relative w-full overflow-hidden bg-slate-50 px-4 py-16 text-slate-900 dark:bg-slate-950 dark:text-slate-100 sm:px-6 sm:py-20 lg:py-24">
      <style>{`
        @keyframes skelcard-shimmer {
          0% { transform: translateX(-100%); }
          100% { transform: translateX(100%); }
        }
        @keyframes skelcard-pulse {
          0%, 100% { opacity: 1; }
          50% { opacity: 0.42; }
        }
        @keyframes skelcard-wave {
          0% { background-position: 200% 0; }
          100% { background-position: -200% 0; }
        }
        @keyframes skelcard-spin {
          to { transform: rotate(360deg); }
        }
        .skelcard-sweep { animation: skelcard-shimmer 1.6s ease-in-out infinite; }
        .skelcard-pulse { animation: skelcard-pulse 1.5s ease-in-out infinite; }
        .skelcard-wave {
          background-image: linear-gradient(90deg, rgba(148,163,184,0) 0%, rgba(148,163,184,0.45) 50%, rgba(148,163,184,0) 100%);
          background-size: 200% 100%;
          animation: skelcard-wave 1.9s linear infinite;
        }
        .skelcard-spin { animation: skelcard-spin 0.9s linear infinite; }
        @media (prefers-reduced-motion: reduce) {
          .skelcard-sweep { display: none; }
          .skelcard-pulse, .skelcard-wave, .skelcard-spin { animation: none !important; }
        }
      `}</style>

      {/* soft ambient wash */}
      <div
        aria-hidden="true"
        className="pointer-events-none absolute inset-x-0 -top-24 mx-auto h-64 max-w-4xl rounded-full bg-gradient-to-r from-indigo-300/30 via-violet-300/20 to-sky-300/30 blur-3xl dark:from-indigo-500/10 dark:via-violet-500/10 dark:to-sky-500/10"
      />

      <div className="relative mx-auto max-w-6xl">
        <header className="max-w-2xl">
          <span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1 text-xs font-medium uppercase tracking-wider text-indigo-600 shadow-sm dark:border-slate-800 dark:bg-slate-900 dark:text-indigo-300">
            <svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" aria-hidden="true">
              <rect x="3" y="4" width="18" height="6" rx="1.5" fill="currentColor" opacity="0.35" />
              <rect x="3" y="13" width="12" height="4" rx="1.5" fill="currentColor" />
            </svg>
            Loading states
          </span>
          <h2 className="mt-5 text-3xl font-semibold tracking-tight text-slate-900 dark:text-white sm:text-4xl">
            Card skeletons that hold their shape
          </h2>
          <p className="mt-3 text-base leading-relaxed text-slate-600 dark:text-slate-400">
            Content-shaped placeholders keep the layout stable while data
            loads, so nothing jumps when it arrives. Switch the shimmer style,
            hold the skeleton to inspect it, or simulate a fresh fetch.
          </p>
        </header>

        {/* control panel */}
        <div className="mt-8 flex flex-col gap-5 rounded-2xl border border-slate-200 bg-white/80 p-4 shadow-sm backdrop-blur dark:border-slate-800 dark:bg-slate-900/70 sm:flex-row sm:flex-wrap sm:items-end sm:gap-6 sm:p-5">
          {/* reload */}
          <div className="flex flex-col gap-1.5">
            <span className="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
              Fetch
            </span>
            <button
              type="button"
              onClick={() => setCycle((c) => c + 1)}
              className={`inline-flex items-center justify-center gap-2 rounded-xl bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-indigo-500 active:bg-indigo-700 ${RING}`}
            >
              <svg
                viewBox="0 0 24 24"
                fill="none"
                className={`h-4 w-4${showSkeleton && !reduced ? " skelcard-spin" : ""}`}
                aria-hidden="true"
              >
                <path
                  d="M20 11a8 8 0 1 0-.6 3"
                  stroke="currentColor"
                  strokeWidth="2"
                  strokeLinecap="round"
                />
                <path
                  d="M20 4v5h-5"
                  stroke="currentColor"
                  strokeWidth="2"
                  strokeLinecap="round"
                  strokeLinejoin="round"
                />
              </svg>
              {showSkeleton ? "Loading…" : "Simulate reload"}
            </button>
          </div>

          {/* hold switch */}
          <div className="flex flex-col gap-1.5">
            <span className="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
              Inspect
            </span>
            <div className="flex items-center gap-3">
              <button
                type="button"
                role="switch"
                aria-checked={hold}
                aria-label="Hold skeleton in view"
                onClick={() => setHold((h) => !h)}
                className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors ${RING} ${
                  hold ? "bg-indigo-600" : "bg-slate-300 dark:bg-slate-700"
                }`}
              >
                <span
                  className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition-transform ${
                    hold ? "translate-x-5" : "translate-x-0.5"
                  }`}
                />
              </button>
              <span className="text-sm font-medium text-slate-700 dark:text-slate-300">
                Hold skeleton
              </span>
            </div>
          </div>

          {/* style radiogroup */}
          <div className="flex flex-col gap-1.5">
            <span
              id="skelcard-style-label"
              className="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400"
            >
              Animation
            </span>
            <div
              role="radiogroup"
              aria-labelledby="skelcard-style-label"
              className="inline-flex rounded-xl border border-slate-200 bg-slate-100 p-1 dark:border-slate-800 dark:bg-slate-800/60"
            >
              {STYLES.map((s, i) => {
                const active = skelStyle === s.id;
                return (
                  <button
                    key={s.id}
                    ref={(el) => {
                      styleRefs.current[i] = el;
                    }}
                    type="button"
                    role="radio"
                    aria-checked={active}
                    tabIndex={active ? 0 : -1}
                    onClick={() => setSkelStyle(s.id)}
                    onKeyDown={(e) => onStyleKey(e, i)}
                    className={`rounded-lg px-3 py-1.5 text-sm font-medium transition-colors ${RING} ${
                      active
                        ? "bg-white text-slate-900 shadow-sm dark:bg-slate-950 dark:text-white"
                        : "text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200"
                    }`}
                  >
                    {s.label}
                  </button>
                );
              })}
            </div>
          </div>

          {/* count stepper */}
          <div className="flex flex-col gap-1.5">
            <span
              id="skelcard-count-label"
              className="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400"
            >
              Cards
            </span>
            <div
              role="group"
              aria-labelledby="skelcard-count-label"
              className="inline-flex items-center gap-1 rounded-xl border border-slate-200 bg-white p-1 dark:border-slate-800 dark:bg-slate-950"
            >
              <button
                type="button"
                aria-label="Show fewer cards"
                disabled={count <= MIN_CARDS}
                onClick={() => setCount((c) => Math.max(MIN_CARDS, c - 1))}
                className={`inline-flex h-8 w-8 items-center justify-center rounded-lg text-slate-700 transition-colors hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-40 dark:text-slate-300 dark:hover:bg-slate-800 ${RING}`}
              >
                <svg viewBox="0 0 24 24" className="h-4 w-4" aria-hidden="true">
                  <path
                    d="M5 12h14"
                    stroke="currentColor"
                    strokeWidth="2"
                    strokeLinecap="round"
                  />
                </svg>
              </button>
              <span
                aria-live="polite"
                className="w-7 text-center text-sm font-semibold tabular-nums text-slate-900 dark:text-white"
              >
                {count}
              </span>
              <button
                type="button"
                aria-label="Show more cards"
                disabled={count >= MAX_CARDS}
                onClick={() => setCount((c) => Math.min(MAX_CARDS, c + 1))}
                className={`inline-flex h-8 w-8 items-center justify-center rounded-lg text-slate-700 transition-colors hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-40 dark:text-slate-300 dark:hover:bg-slate-800 ${RING}`}
              >
                <svg viewBox="0 0 24 24" className="h-4 w-4" aria-hidden="true">
                  <path
                    d="M12 5v14M5 12h14"
                    stroke="currentColor"
                    strokeWidth="2"
                    strokeLinecap="round"
                  />
                </svg>
              </button>
            </div>
          </div>
        </div>

        {/* live status */}
        <p className="sr-only" role="status" aria-live="polite">
          {showSkeleton
            ? "Loading cards"
            : `${visible.length} cards loaded`}
        </p>

        {/* grid */}
        <div
          aria-busy={showSkeleton}
          className="mt-8 grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3"
        >
          {showSkeleton
            ? Array.from({ length: count }).map((_, i) => (
                <div
                  key={`skel-${i}`}
                  aria-hidden="true"
                  className="overflow-hidden rounded-2xl border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-900"
                >
                  <Skel
                    skelStyle={skelStyle}
                    reduced={reduced}
                    className="mb-4 h-40 w-full rounded-xl"
                  />
                  <Skel
                    skelStyle={skelStyle}
                    reduced={reduced}
                    className="mb-4 h-5 w-24 rounded-full"
                  />
                  <Skel
                    skelStyle={skelStyle}
                    reduced={reduced}
                    className="mb-2.5 h-4 w-11/12 rounded"
                  />
                  <Skel
                    skelStyle={skelStyle}
                    reduced={reduced}
                    className="mb-5 h-4 w-3/4 rounded"
                  />
                  <Skel
                    skelStyle={skelStyle}
                    reduced={reduced}
                    className="mb-2 h-3 w-full rounded"
                  />
                  <Skel
                    skelStyle={skelStyle}
                    reduced={reduced}
                    className="mb-2 h-3 w-full rounded"
                  />
                  <Skel
                    skelStyle={skelStyle}
                    reduced={reduced}
                    className="mb-6 h-3 w-2/3 rounded"
                  />
                  <div className="flex items-center gap-3">
                    <Skel
                      skelStyle={skelStyle}
                      reduced={reduced}
                      className="h-9 w-9 shrink-0 rounded-full"
                    />
                    <div className="flex-1">
                      <Skel
                        skelStyle={skelStyle}
                        reduced={reduced}
                        className="mb-2 h-3 w-28 rounded"
                      />
                      <Skel
                        skelStyle={skelStyle}
                        reduced={reduced}
                        className="h-3 w-16 rounded"
                      />
                    </div>
                  </div>
                </div>
              ))
            : visible.map((item, i) => {
                const isSaved = !!saved[item.id];
                return (
                  <motion.article
                    key={item.id}
                    initial={reduced ? false : { opacity: 0, y: 10 }}
                    animate={{ opacity: 1, y: 0 }}
                    transition={{
                      duration: 0.35,
                      ease: "easeOut",
                      delay: reduced ? 0 : i * 0.06,
                    }}
                    className="group flex flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm transition-shadow hover:shadow-md dark:border-slate-800 dark:bg-slate-900"
                  >
                    <div
                      className={`relative h-40 w-full overflow-hidden bg-gradient-to-br ${item.cover}`}
                    >
                      <span
                        aria-hidden="true"
                        className="pointer-events-none absolute -right-3 -top-6 select-none text-[7rem] font-black leading-none text-white/15"
                      >
                        {item.tag.charAt(0)}
                      </span>
                      <span className="absolute bottom-3 left-3 inline-flex items-center rounded-full bg-black/25 px-2.5 py-1 text-xs font-semibold text-white backdrop-blur-sm">
                        {item.tag}
                      </span>
                      <button
                        type="button"
                        aria-pressed={isSaved}
                        aria-label={
                          isSaved
                            ? `Remove ${item.title} from saved`
                            : `Save ${item.title}`
                        }
                        onClick={() =>
                          setSaved((s) => ({ ...s, [item.id]: !s[item.id] }))
                        }
                        className={`absolute right-3 top-3 inline-flex h-9 w-9 items-center justify-center rounded-full bg-white/90 text-slate-700 shadow-sm transition-colors hover:bg-white dark:bg-slate-950/70 dark:text-slate-200 dark:hover:bg-slate-950 ${RING} focus-visible:ring-offset-transparent`}
                      >
                        <svg
                          viewBox="0 0 24 24"
                          className="h-4 w-4"
                          fill={isSaved ? "currentColor" : "none"}
                          aria-hidden="true"
                        >
                          <path
                            d="M6 4h12a1 1 0 0 1 1 1v15l-7-4-7 4V5a1 1 0 0 1 1-1Z"
                            stroke="currentColor"
                            strokeWidth="2"
                            strokeLinejoin="round"
                          />
                        </svg>
                      </button>
                    </div>

                    <div className="flex flex-1 flex-col p-5">
                      <h3 className="text-lg font-semibold leading-snug tracking-tight text-slate-900 line-clamp-2 dark:text-white">
                        {item.title}
                      </h3>
                      <p className="mt-2 text-sm leading-relaxed text-slate-600 line-clamp-3 dark:text-slate-400">
                        {item.excerpt}
                      </p>
                      <div className="mt-auto flex items-center gap-3 pt-5">
                        <span
                          aria-hidden="true"
                          className={`inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-gradient-to-br text-xs font-bold text-white ${item.avatar}`}
                        >
                          {item.initials}
                        </span>
                        <div className="min-w-0">
                          <p className="truncate text-sm font-medium text-slate-800 dark:text-slate-200">
                            {item.author}
                          </p>
                          <p className="text-xs text-slate-500 dark:text-slate-500">
                            {item.read}
                          </p>
                        </div>
                      </div>
                    </div>
                  </motion.article>
                );
              })}
        </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 →