Web InnoventixFreeCode

Blog Card

Original · free

A blog post card with cover image, tag and meta row.

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

import { useState } from "react";
import { motion, AnimatePresence, useReducedMotion } from "motion/react";

type Post = {
  id: string;
  img: string;
  tag: string;
  tagTone: "indigo" | "emerald" | "rose" | "amber" | "sky" | "violet";
  title: string;
  excerpt: string;
  author: string;
  initials: string;
  date: string;
  readMins: number;
  href: string;
};

const POSTS: Post[] = [
  {
    id: "rsc",
    img: "/img/gallery/g07.webp",
    tag: "Engineering",
    tagTone: "indigo",
    title: "React Server Components in production: what actually broke",
    excerpt:
      "We shipped RSC to 40M monthly users. Here are the caching gotchas, the streaming wins, and the three patterns we now ban in code review.",
    author: "Priya Nair",
    initials: "PN",
    date: "Jul 9, 2026",
    readMins: 11,
    href: "https://example.com/blog/rsc-in-production",
  },
  {
    id: "css",
    img: "/img/gallery/g14.webp",
    tag: "Design",
    tagTone: "violet",
    title: "Container queries killed our breakpoint spaghetti",
    excerpt:
      "One component, four contexts, zero media queries. A field report on rebuilding a design system around the element, not the viewport.",
    author: "Marcus Feld",
    initials: "MF",
    date: "Jun 28, 2026",
    readMins: 7,
    href: "https://example.com/blog/container-queries",
  },
  {
    id: "pg",
    img: "/img/gallery/g22.webp",
    tag: "Databases",
    tagTone: "emerald",
    title: "Postgres at 2 billion rows without a rewrite",
    excerpt:
      "Partial indexes, BRIN, and the partitioning strategy that let us skip the Cassandra migration our board kept asking for.",
    author: "Lena Osei",
    initials: "LO",
    date: "Jun 15, 2026",
    readMins: 14,
    href: "https://example.com/blog/postgres-2b-rows",
  },
];

const TONE: Record<
  Post["tagTone"],
  { chip: string; dot: string }
> = {
  indigo: {
    chip:
      "bg-indigo-50 text-indigo-700 ring-indigo-200 dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-400/20",
    dot: "bg-indigo-500",
  },
  emerald: {
    chip:
      "bg-emerald-50 text-emerald-700 ring-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-400/20",
    dot: "bg-emerald-500",
  },
  rose: {
    chip:
      "bg-rose-50 text-rose-700 ring-rose-200 dark:bg-rose-500/10 dark:text-rose-300 dark:ring-rose-400/20",
    dot: "bg-rose-500",
  },
  amber: {
    chip:
      "bg-amber-50 text-amber-800 ring-amber-200 dark:bg-amber-500/10 dark:text-amber-300 dark:ring-amber-400/20",
    dot: "bg-amber-500",
  },
  sky: {
    chip:
      "bg-sky-50 text-sky-700 ring-sky-200 dark:bg-sky-500/10 dark:text-sky-300 dark:ring-sky-400/20",
    dot: "bg-sky-500",
  },
  violet: {
    chip:
      "bg-violet-50 text-violet-700 ring-violet-200 dark:bg-violet-500/10 dark:text-violet-300 dark:ring-violet-400/20",
    dot: "bg-violet-500",
  },
};

function HeartIcon({ filled }: { filled: boolean }) {
  return (
    <svg
      viewBox="0 0 24 24"
      className="h-[18px] w-[18px]"
      fill={filled ? "currentColor" : "none"}
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <path d="M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.6l-1-1a5.5 5.5 0 1 0-7.8 7.8l1 1L12 21l7.8-7.6 1-1a5.5 5.5 0 0 0 0-7.8Z" />
    </svg>
  );
}

function BookmarkIcon({ filled }: { filled: boolean }) {
  return (
    <svg
      viewBox="0 0 24 24"
      className="h-[18px] w-[18px]"
      fill={filled ? "currentColor" : "none"}
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <path d="M6 3h12a1 1 0 0 1 1 1v17l-7-4-7 4V4a1 1 0 0 1 1-1Z" />
    </svg>
  );
}

function LinkIcon() {
  return (
    <svg
      viewBox="0 0 24 24"
      className="h-[18px] w-[18px]"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <path d="M10 13a5 5 0 0 0 7.07 0l2.83-2.83a5 5 0 0 0-7.07-7.07L11.5 4.5" />
      <path d="M14 11a5 5 0 0 0-7.07 0L4.1 13.83a5 5 0 0 0 7.07 7.07L12.5 19.5" />
    </svg>
  );
}

function CheckIcon() {
  return (
    <svg
      viewBox="0 0 24 24"
      className="h-[18px] w-[18px]"
      fill="none"
      stroke="currentColor"
      strokeWidth={2.4}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <path d="m5 13 4 4L19 7" />
    </svg>
  );
}

function ArrowIcon() {
  return (
    <svg
      viewBox="0 0 24 24"
      className="h-4 w-4"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <path d="M5 12h14" />
      <path d="m13 6 6 6-6 6" />
    </svg>
  );
}

const iconBtn =
  "inline-flex h-9 w-9 items-center justify-center rounded-full text-slate-500 transition-colors outline-none hover:bg-slate-100 hover:text-slate-800 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-slate-400 dark:hover:bg-white/10 dark:hover:text-slate-100 dark:focus-visible:ring-offset-slate-900";

function TagChip({ post }: { post: Post }) {
  const tone = TONE[post.tagTone];
  return (
    <span
      className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-semibold ring-1 ring-inset ${tone.chip}`}
    >
      <span className={`h-1.5 w-1.5 rounded-full ${tone.dot}`} aria-hidden="true" />
      {post.tag}
    </span>
  );
}

function Avatar({ post }: { post: Post }) {
  const tone = TONE[post.tagTone];
  return (
    <span
      className={`inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-[11px] font-bold text-white ${tone.dot}`}
      aria-hidden="true"
    >
      {post.initials}
    </span>
  );
}

function CardActions({ post }: { post: Post }) {
  const reduce = useReducedMotion();
  const [liked, setLiked] = useState(false);
  const [saved, setSaved] = useState(false);
  const [copied, setCopied] = useState(false);
  const [likeBurst, setLikeBurst] = useState(0);

  async function copyLink() {
    try {
      await navigator.clipboard.writeText(post.href);
      setCopied(true);
      window.setTimeout(() => setCopied(false), 1600);
    } catch {
      setCopied(false);
    }
  }

  function toggleLike() {
    setLiked((v) => {
      if (!v) setLikeBurst((n) => n + 1);
      return !v;
    });
  }

  return (
    <div className="flex items-center gap-1">
      <button
        type="button"
        onClick={toggleLike}
        aria-pressed={liked}
        aria-label={liked ? "Unlike this post" : "Like this post"}
        className={`${iconBtn} relative ${
          liked ? "text-rose-500 hover:text-rose-600 dark:text-rose-400" : ""
        }`}
      >
        <motion.span
          key={likeBurst}
          initial={reduce ? false : { scale: 1 }}
          animate={reduce ? undefined : liked ? { scale: [1, 1.35, 1] } : { scale: 1 }}
          transition={{ duration: 0.32, ease: "easeOut" }}
          className="inline-flex"
        >
          <HeartIcon filled={liked} />
        </motion.span>
      </button>

      <button
        type="button"
        onClick={() => setSaved((v) => !v)}
        aria-pressed={saved}
        aria-label={saved ? "Remove from saved" : "Save for later"}
        className={`${iconBtn} ${
          saved ? "text-amber-500 hover:text-amber-600 dark:text-amber-400" : ""
        }`}
      >
        <BookmarkIcon filled={saved} />
      </button>

      <button
        type="button"
        onClick={copyLink}
        aria-label={copied ? "Link copied" : "Copy article link"}
        className={`${iconBtn} ${copied ? "text-emerald-500 dark:text-emerald-400" : ""}`}
      >
        <AnimatePresence mode="wait" initial={false}>
          <motion.span
            key={copied ? "done" : "copy"}
            initial={reduce ? false : { opacity: 0, scale: 0.6 }}
            animate={{ opacity: 1, scale: 1 }}
            exit={reduce ? undefined : { opacity: 0, scale: 0.6 }}
            transition={{ duration: 0.14 }}
            className="inline-flex"
          >
            {copied ? <CheckIcon /> : <LinkIcon />}
          </motion.span>
        </AnimatePresence>
      </button>
    </div>
  );
}

function MetaRow({ post }: { post: Post }) {
  return (
    <div className="flex items-center gap-2 text-xs text-slate-500 dark:text-slate-400">
      <Avatar post={post} />
      <span className="font-medium text-slate-700 dark:text-slate-300">
        {post.author}
      </span>
      <span aria-hidden="true" className="text-slate-300 dark:text-slate-600">
        &middot;
      </span>
      <time>{post.date}</time>
      <span aria-hidden="true" className="text-slate-300 dark:text-slate-600">
        &middot;
      </span>
      <span>{post.readMins} min read</span>
    </div>
  );
}

function VerticalCard({ post }: { post: Post }) {
  return (
    <article className="group relative flex flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm transition-all duration-300 hover:-translate-y-1 hover:shadow-xl hover:shadow-slate-900/10 dark:border-white/10 dark:bg-slate-900 dark:hover:shadow-black/40">
      <div className="relative aspect-[16/10] overflow-hidden bg-slate-100 dark:bg-slate-800">
        {/* eslint-disable-next-line @next/next/no-img-element */}
        <img
          src={post.img}
          alt={`Cover illustration for ${post.title}`}
          loading="lazy"
          draggable={false}
          className="cardblog-fade h-full w-full object-cover transition-transform duration-500 ease-out group-hover:scale-[1.05]"
        />
        <div className="absolute left-3 top-3">
          <TagChip post={post} />
        </div>
      </div>

      <div className="flex flex-1 flex-col p-5">
        <a
          href={post.href}
          target="_blank"
          rel="noopener"
          className="rounded-sm 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"
        >
          <h3 className="text-lg font-semibold leading-snug tracking-tight text-slate-900 decoration-2 underline-offset-2 group-hover:underline dark:text-white">
            {post.title}
          </h3>
        </a>
        <p className="mt-2 line-clamp-2 text-sm leading-relaxed text-slate-600 dark:text-slate-400">
          {post.excerpt}
        </p>

        <div className="mt-4 border-t border-slate-100 pt-4 dark:border-white/10">
          <MetaRow post={post} />
        </div>

        <div className="mt-4 flex items-center justify-between">
          <a
            href={post.href}
            target="_blank"
            rel="noopener"
            className="inline-flex items-center gap-1.5 rounded-md text-sm font-semibold text-indigo-600 outline-none transition-colors hover:text-indigo-700 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-indigo-400 dark:hover:text-indigo-300 dark:focus-visible:ring-offset-slate-900"
          >
            Read article
            <span className="transition-transform duration-200 group-hover:translate-x-0.5">
              <ArrowIcon />
            </span>
          </a>
          <CardActions post={post} />
        </div>
      </div>
    </article>
  );
}

function HorizontalCard({ post }: { post: Post }) {
  return (
    <article className="group relative flex flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm transition-all duration-300 hover:shadow-lg hover:shadow-slate-900/10 sm:flex-row dark:border-white/10 dark:bg-slate-900 dark:hover:shadow-black/40">
      <div className="relative aspect-[16/10] overflow-hidden bg-slate-100 sm:aspect-auto sm:w-56 sm:shrink-0 dark:bg-slate-800">
        {/* eslint-disable-next-line @next/next/no-img-element */}
        <img
          src={post.img}
          alt={`Cover illustration for ${post.title}`}
          loading="lazy"
          draggable={false}
          className="h-full w-full object-cover transition-transform duration-500 ease-out group-hover:scale-[1.05]"
        />
      </div>

      <div className="flex flex-1 flex-col p-5">
        <div>
          <TagChip post={post} />
        </div>
        <a
          href={post.href}
          target="_blank"
          rel="noopener"
          className="mt-3 rounded-sm 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"
        >
          <h3 className="text-lg font-semibold leading-snug tracking-tight text-slate-900 decoration-2 underline-offset-2 group-hover:underline dark:text-white">
            {post.title}
          </h3>
        </a>
        <p className="mt-2 line-clamp-2 text-sm leading-relaxed text-slate-600 dark:text-slate-400">
          {post.excerpt}
        </p>

        <div className="mt-auto flex items-center justify-between pt-4">
          <MetaRow post={post} />
          <CardActions post={post} />
        </div>
      </div>
    </article>
  );
}

function CompactCard({ post }: { post: Post }) {
  return (
    <article className="group relative flex items-center gap-4 rounded-xl border border-slate-200 bg-white p-3 shadow-sm transition-colors hover:border-slate-300 hover:bg-slate-50 dark:border-white/10 dark:bg-slate-900 dark:hover:border-white/20 dark:hover:bg-slate-800/60">
      <div className="relative h-16 w-16 shrink-0 overflow-hidden rounded-lg bg-slate-100 dark:bg-slate-800">
        {/* eslint-disable-next-line @next/next/no-img-element */}
        <img
          src={post.img}
          alt=""
          loading="lazy"
          draggable={false}
          className="h-full w-full object-cover"
        />
      </div>
      <div className="min-w-0 flex-1">
        <div className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-wide">
          <span className={`h-1.5 w-1.5 rounded-full ${TONE[post.tagTone].dot}`} aria-hidden="true" />
          <span className="text-slate-500 dark:text-slate-400">{post.tag}</span>
          <span aria-hidden="true" className="text-slate-300 dark:text-slate-600">
            &middot;
          </span>
          <span className="text-slate-400 dark:text-slate-500">{post.readMins} min</span>
        </div>
        <a
          href={post.href}
          target="_blank"
          rel="noopener"
          className="mt-1 block rounded-sm 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"
        >
          <h3 className="truncate text-sm font-semibold text-slate-900 group-hover:text-indigo-600 dark:text-white dark:group-hover:text-indigo-400">
            {post.title}
          </h3>
        </a>
      </div>
      <span className="text-slate-300 transition-transform duration-200 group-hover:translate-x-0.5 group-hover:text-slate-500 dark:text-slate-600 dark:group-hover:text-slate-300">
        <ArrowIcon />
      </span>
    </article>
  );
}

export default function CardBlog() {
  return (
    <section className="relative w-full bg-slate-50 px-4 py-16 sm:px-6 sm:py-24 dark:bg-slate-950">
      <style>{`
        @keyframes cardblog-rise {
          from { opacity: 0; transform: translateY(14px); }
          to { opacity: 1; transform: translateY(0); }
        }
        .cardblog-fade { animation: cardblog-rise 0.5s ease-out both; }
        @media (prefers-reduced-motion: reduce) {
          .cardblog-fade { animation: none !important; }
        }
      `}</style>

      <div className="mx-auto max-w-6xl">
        <header className="mx-auto max-w-2xl text-center">
          <p className="text-xs font-semibold uppercase tracking-[0.2em] text-indigo-600 dark:text-indigo-400">
            From the blog
          </p>
          <h2 className="mt-3 text-3xl font-bold tracking-tight text-slate-900 sm:text-4xl dark:text-white">
            Field notes on shipping fast
          </h2>
          <p className="mt-3 text-base leading-relaxed text-slate-600 dark:text-slate-400">
            Post cards with cover art, a topic tag and an author meta row. Like,
            save and copy the link right from the card.
          </p>
        </header>

        <div className="mt-12 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
          {POSTS.map((post) => (
            <VerticalCard key={post.id} post={post} />
          ))}
        </div>

        <div className="mt-8">
          <HorizontalCard post={POSTS[0]} />
        </div>

        <div className="mx-auto mt-8 grid max-w-3xl grid-cols-1 gap-3 sm:grid-cols-2">
          {POSTS.map((post) => (
            <CompactCard key={post.id} post={post} />
          ))}
        </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 →