Blog cards
A grid of post cards with an image, tag, title, excerpt and a mono meta line.
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/blog-cards

Design5 Nov, 4 min
Why our focus ring is two pixels
A look at how thin rings fail on dark surfaces, and the offset that fixed it.

Engineering29 Oct, 7 min
Shipping a design system with no build step
Copy the source, own the code. What we kept and what we cut.
Two cards in a two column grid.
Three columns

Design5 Nov, 6 min
Spacing scales that survive a redesign
Four steps, one base unit, and the rule that kept every layout aligned.

Motion2 Nov, 5 min
Motion budgets for dense interfaces
Under 200ms, transform and opacity only. How we decide what earns motion.

Accessibility30 Oct, 8 min
Testing contrast in both themes
Automated checks catch most failures. The rest need a zoomed-in look.
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
| Prop | Type | Default | Description |
|---|---|---|---|
| posts | { title; excerpt; image; href; meta; tag? }[] | The posts. Images are decorative and the title is the link. | |
| columns | 2 | 3 | 3 | Maximum columns. Fewer on narrow containers. |
Source
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