Skip to content
goodtabs UI
BrowseBrowse components

Blog cards

A grid of post cards with an image, tag, title, excerpt and a mono meta line.

npx shadcn@latest add https://ui.goodtabs.co/r/blog-cards.json

With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/blog-cards

Two cards in a two column grid.

Three columns

Three cards, two columns from 32rem and three from 48rem.

Usage

import { BlogCards } from "@/components/blog-cards"

<BlogCards posts={[{ title: "Why our focus ring is two pixels", excerpt: "A look at thin rings on dark surfaces.", image: "/images/ring.jpg", href: "/blog/focus-ring", meta: "5 Nov, 4 min", tag: "Design" }]} />

Props

PropTypeDefaultDescription
posts{ title; excerpt; image; href; meta; tag? }[]The posts. Images are decorative and the title is the link.
columns2 | 33Maximum columns. Fewer on narrow containers.

Source

blog-cards/blog-cards.tsx
import * as React from "react";
import { cn } from "@/lib/utils";

type Post = {
  title: string;
  excerpt: string;
  /** Image source. The image is decorative, the title carries the meaning. */
  image: string;
  /** Link target for the whole card. */
  href: string;
  /** A mono meta line: a date and a read time. */
  meta: string;
  /** A short category label. */
  tag?: string;
};

type BlogCardsProps = Omit<React.ComponentProps<"ul">, "children"> & {
  posts: Post[];
  /** Columns once the container is wide enough. */
  columns?: 2 | 3;
};

const COLUMNS = { 2: "@lg:grid-cols-2", 3: "@lg:grid-cols-2 @3xl:grid-cols-3" } as const;

/**
 * A grid of post cards: image, tag, title, excerpt and a mono meta line.
 * The whole card is one link on the title, stretched with a pseudo-element.
 */
export function BlogCards({ posts, columns = 3, className, ...props }: BlogCardsProps) {
  return (
    <div className="@container w-full">
      <ul data-slot="blog-cards" className={cn("m-0 grid list-none gap-6 p-0", COLUMNS[columns], className)} {...props}>
        {posts.map((post) => (
          <li key={post.href + post.title} className="group/post relative flex flex-col gap-3">
            <div className="overflow-hidden rounded-lg border bg-muted">
              {/* eslint-disable-next-line @next/next/no-img-element */}
              <img
                src={post.image}
                alt=""
                loading="lazy"
                className="aspect-3/2 w-full object-cover transition-transform duration-200 ease-out group-hover/post:scale-[1.02] motion-reduce:transition-none"
              />
            </div>
            <div className="flex flex-col gap-1.5">
              <p className="m-0 flex items-center gap-2 font-mono text-xs leading-4 text-muted-foreground">
                {post.tag && (
                  <>
                    <span className="text-foreground/70">{post.tag}</span>
                    <span aria-hidden="true">·</span>
                  </>
                )}
                <span>{post.meta}</span>
              </p>
              <h3 className="m-0 text-sm leading-5 font-medium text-foreground">
                <a
                  href={post.href}
                  className="rounded-sm outline-none after:absolute after:inset-0 focus-visible:after:rounded-lg focus-visible:after:ring-2 focus-visible:after:ring-ring"
                >
                  {post.title}
                </a>
              </h3>
              <p className="m-0 line-clamp-2 text-sm text-muted-foreground">{post.excerpt}</p>
            </div>
          </li>
        ))}
      </ul>
    </div>
  );
}

For AI agents

Hand your agent this page, the markdown at /components/blog-cards/prompt.md, or the Copy AI prompt button above.

When to use

  • The latest posts block on a landing page
  • A blog index with an image per post
  • A changelog or case study teaser row

When not to use

  • Dense text lists, which want a table or plain rows
  • Image-only collections, which want the gallery section
  • Product cards with prices

Agent notes

  • Server component; uses a plain img and an a so it has no router dependency, swap in next/image or next/link if you want them
  • The title link is stretched over the card with an after pseudo-element, so keep one link per card
  • Images use aspect-3/2 with object-cover, so any source ratio works
  • Excerpt is clamped to two lines