A grid of three blog post cards under a heading and an "All articles" link. Each card has a 16:9 cover, a category tag, a reading time with a clock icon, a title, a short excerpt, and the author's initials, name and date. The cards have no border or box, so the covers do the framing. The whole card is clickable. The grid is three columns on desktop, two on tablets and one on phones.
const posts = [
{
tag: "Growing guides",
minutes: 6,
title: "The five houseplants that forgive a missed watering",
excerpt: "Hard to kill, easy to find and happy in a north-facing room. Start here if your last fern did not make it.",
author: "Odette Marsh",
initials: "OM",
date: "12 May 2025",
cover: "leaf",
},
{
tag: "Studio notes",
minutes: 4,
title: "Why we stopped selling plastic nursery pots",
excerpt: "What we switched to, what it cost us and the one thing customers noticed first.",
author: "Tobias Venn",
initials: "TV",
date: "28 April 2025",
cover: "pots",
},
{
tag: "Care calendar",
minutes: 9,
title: "A month-by-month repotting plan for small flats",
excerpt: "Which plants to move up a size in spring, which to leave alone, and how to do it on a kitchen counter without the mess.",
author: "Odette Marsh",
initials: "OM",
date: "9 April 2025",
cover: "sun",
},
];
function Cover({ kind }: { kind: string }) {
if (kind === "leaf") {
return (
<svg viewBox="0 0 400 225" preserveAspectRatio="xMidYMid slice" aria-hidden="true" className="h-full w-full">
<rect width="400" height="225" className="fill-rose-100" />
<circle cx="318" cy="52" r="74" className="fill-rose-200" />
<path d="M200 205c-62-34-80-98-34-150 54 30 76 92 34 150z" className="fill-emerald-600" />
<path d="M200 205c64-22 100-72 84-128-58 16-98 66-84 128z" className="fill-emerald-500" />
<path d="M200 205c-14-52-10-96 14-140" fill="none" strokeWidth="3" className="stroke-emerald-800" />
<rect y="205" width="400" height="20" className="fill-rose-300" />
</svg>
);
}
if (kind === "pots") {
return (
<svg viewBox="0 0 400 225" preserveAspectRatio="xMidYMid slice" aria-hidden="true" className="h-full w-full">
<rect width="400" height="225" className="fill-amber-100" />
<rect y="170" width="400" height="55" className="fill-amber-200" />
<path d="M70 96h84l-12 84H82z" className="fill-orange-500" />
<rect x="62" y="84" width="100" height="18" rx="4" className="fill-orange-600" />
<path d="M176 66h72l-10 114h-52z" className="fill-rose-500" />
<rect x="168" y="54" width="88" height="18" rx="4" className="fill-rose-600" />
<path d="M270 116h70l-10 64h-50z" className="fill-stone-700" />
<rect x="263" y="105" width="84" height="16" rx="4" className="fill-stone-800" />
</svg>
);
}
return (
<svg viewBox="0 0 400 225" preserveAspectRatio="xMidYMid slice" aria-hidden="true" className="h-full w-full">
<rect width="400" height="225" className="fill-sky-100" />
<circle cx="96" cy="78" r="44" className="fill-amber-300" />
<path d="M0 225V160c60-34 120-34 180 0s150 30 220-14v79z" className="fill-emerald-500" />
<path d="M0 225v-34c80-26 150-20 220 6s120 22 180 0v28z" className="fill-emerald-700" />
<rect x="262" y="60" width="74" height="74" rx="8" className="fill-white" />
<rect x="262" y="60" width="74" height="18" rx="8" className="fill-rose-500" />
<rect x="276" y="92" width="12" height="12" rx="2" className="fill-sky-200" />
<rect x="294" y="92" width="12" height="12" rx="2" className="fill-sky-200" />
<rect x="312" y="92" width="12" height="12" rx="2" className="fill-rose-400" />
<rect x="276" y="110" width="12" height="12" rx="2" className="fill-sky-200" />
<rect x="294" y="110" width="12" height="12" rx="2" className="fill-sky-200" />
</svg>
);
}
export default function BlogPostCardGridWithTags() {
return (
<section className="bg-white px-6 py-16 text-slate-900 sm:py-20">
<div className="mx-auto max-w-6xl">
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<h2 className="text-3xl font-semibold tracking-tight sm:text-4xl">From the potting bench</h2>
<p className="mt-3 text-slate-600">Plant care advice and shop news from the Mosswick team.</p>
</div>
<a href="#" className="inline-flex h-10 items-center gap-1.5 text-sm font-semibold text-rose-700 hover:text-rose-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-600 focus-visible:ring-offset-2">
All articles
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="h-4 w-4"><path d="M4 10h12M11 5l5 5-5 5" /></svg>
</a>
</div>
<ul className="mt-10 grid gap-x-8 gap-y-12 sm:grid-cols-2 lg:grid-cols-3">
{posts.map((p) => (
<li key={p.title}>
<article className="group relative flex h-full flex-col">
<div className="aspect-video overflow-hidden rounded-xl">
<Cover kind={p.cover} />
</div>
<div className="mt-5 flex items-center gap-3 text-sm">
<span className="rounded-full bg-rose-50 px-3 py-1 font-semibold text-rose-700">{p.tag}</span>
<span className="flex items-center gap-1.5 text-slate-600">
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" aria-hidden="true" className="h-4 w-4"><circle cx="10" cy="10" r="7" /><path d="M10 6.5V10l2.5 1.5" /></svg>
{p.minutes} min read
</span>
</div>
<h3 className="mt-4 text-xl font-semibold leading-snug tracking-tight">
<a href="#" className="rounded-sm group-hover:text-rose-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-600 focus-visible:ring-offset-2">
<span aria-hidden="true" className="absolute inset-0" />
{p.title}
</a>
</h3>
<p className="mt-2 flex-1 leading-relaxed text-slate-600">{p.excerpt}</p>
<div className="mt-5 flex items-center gap-3 text-sm">
<span aria-hidden="true" className="flex h-9 w-9 flex-none items-center justify-center rounded-full bg-slate-900 text-xs font-semibold text-white">{p.initials}</span>
<span className="font-semibold">{p.author}</span>
<span aria-hidden="true" className="text-slate-300">/</span>
<span className="text-slate-600">{p.date}</span>
</div>
</article>
</li>
))}
</ul>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/blog-post-card-grid-with-tags.json
A common method is to count the words in the post and divide by about 200 to 230 words a minute, then round up. This component only displays the number in `minutes`, so calculate it wherever you build your post data.
The `article` is `relative` and the title link contains an empty `span` with `absolute inset-0`. That span covers the card, so a click anywhere follows the title link. Keep both classes if you change the layout.
Yes. Add more objects to the array and the grid wraps onto new rows. Six or nine keeps the rows complete on desktop.
Add `line-clamp-2` to the title and `line-clamp-3` to the excerpt. Both are core utilities and cut the text with an ellipsis after that many lines.
No. The grid has no state, so the HTML version works as it is.
Related: Product Card with Price, Rating, Colour Swatches and Add to Cart, Testimonial Cards with Star Ratings and Initials Avatars, Split Hero with Email Capture, Button Set: Primary, Secondary, Ghost, Icon and Loading.
More cards or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.