Blocks

Blog Card Grid

A four-state article grid — one stretched link per card with the category and author still independently clickable, a featured post spanning two columns, and deliberate, configurable line clamping so mismatched title lengths still line up.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { ImageOff } from "lucide-react"
import { cn } from "@/lib/utils"
import type { BlogAuthor, BlogCardGridData, BlogCardGridItem } from "./blog-card-grid.contract"

/**
 * The column count is breakpoint-driven because "the featured post takes double width" needs a
 * known count: at 1 column it never spans (a span would push out an implicit column and overflow
 * horizontally at 375px), at 2 it fills the row, at 3 it takes 2/3 — every step stays in the container.
 */
const GRID = "grid gap-6 sm:grid-cols-2 lg:grid-cols-3"

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/blog-card-grid.json

Prompt

Build a React + TypeScript + Tailwind "BlogCardGrid" block (lucide-react
ImageOff) with zod.

Contract
- One zod schema is the single source of truth:
  { status: "loading" | "empty" | "error" | "ready"; heading?, subheading?;
    items: { id, title, href, excerpt?, coverUrl?, coverAlt?,
      category?: { label, href? },
      author?: { name, avatarUrl?, initials?, href? },
      publishedAt?,     // ISO 8601, goes straight into <time dateTime>
      readingMinutes?,  // supplied by the data, never guessed from word count
      featured? }[] }.
- Every href field is refined to reject "" and anything starting with "#":
  a dead anchor is a data error, not a styling detail.
- Component props = z.infer of the schema plus titleLines (default 2),
  excerptLines (default 3), locale (default "en-US"), onRetry? and className.

Behavior
- Four first-class branches: loading (skeleton cards mirroring the real
  anatomy, the first one in the featured shape, plus an sr-only role=status),
  empty ("No posts published yet" panel), error (message + "Try again" only
  when onRetry exists), ready (optional heading block + a <ul> of cards).
- ready renders EVERY item. No MAX_POSTS, no slice — the host paginates.
- Whole-card click, legal HTML: the card is a <li class="relative"> and the
  title's <a> carries after:absolute after:inset-0, so its ::after covers the
  card. Do NOT wrap the card in an <a> (that nests anchors) and never put a
  <button> inside an <a>. The secondary links — the category chip and the
  author name — get "relative z-10" so they sit above that overlay and stay
  independently clickable; a click anywhere else lands on the title link.
  The clamp lives on a <span> INSIDE the anchor, so the anchor itself never
  carries overflow:hidden around its stretching ::after.
- A category with an href renders as a link; without one it renders as a
  plain chip — no clickable look on something that does not click. Same rule
  for the author name.
- Cover: fixed ratio (aspect-video) so the box is already at its final height
  before the image arrives and nothing reflows. onError falls back to a
  centred ImageOff tile on bg-muted — never a broken-image box — and a ref
  callback re-checks (complete && naturalWidth === 0), because a cached image
  can fail before hydration attaches onError. alt comes from coverAlt and
  defaults to "" (deliberately decorative: the title already names the post).
- Featured item: sm:col-span-2 plus sm:flex-row, so it spans two columns and
  puts a half-width, full-height cover beside the text. The grid is
  breakpoint-driven (1 / 2 / 3 columns) precisely because col-span-2 needs a
  known column count — spanning two columns inside a single-column auto-fill
  grid invents an implicit track and overflows the viewport. More than one
  featured item is allowed and never overflows, but in a three-column grid
  the second wide card wraps and leaves the end of the previous row empty:
  CSS grid does not backfill unless you opt into grid-auto-flow: dense, which
  detaches visual order from data order. Put featured posts first.
- Line clamping is deliberate and configurable, not an accidental
  fixed-height crop: titleLines / excerptLines say how many lines may show
  and 0 turns clamping off. The title box additionally reserves
  min-height: <n>lh, so a one-word title and a twenty-word title in the same
  row keep their excerpts and bylines on the same baseline. Use inline style
  (display:-webkit-box, WebkitLineClamp, overflow:hidden) rather than
  line-clamp-N: Tailwind's JIT cannot see a class name assembled from a prop
  at runtime, so line-clamp-[n] would silently disappear.
- Dates: <time dateTime={publishedAt}> keeps the raw ISO string; the visible
  text is Intl.DateTimeFormat(locale, { timeZone: "UTC", ... }) — formatting a
  date-only string in the reader's own zone slips it back a day west of UTC.
  An unparseable string renders verbatim instead of "Invalid Date".
- Reading time comes from readingMinutes, rounded and floored at 1 minute;
  the component never estimates it from the excerpt.
- The byline row is mt-auto, so it stays pinned to the bottom of every card
  once the grid stretches them to equal height.

Rendering & styling
- Semantic tokens only: bg-card cards on border, bg-muted for cover
  placeholders and skeletons, text-muted-foreground for excerpt and meta,
  ring-ring focus-visible rings on all three link types. No hex, no rgb().
- Heading level follows context: with a section heading the cards are h3,
  without one they are h2 — never skip a level.
- The cover's hover zoom is transition-[scale] (Tailwind v4 writes the CSS
  `scale` property, so it must be named in the transition list) and is
  disabled under motion-reduce; nothing about the card depends on it.
- cn() merges className; the root is a <section> labelled by its heading.

Customization levers
- Column count: the grid string is one constant ("grid gap-6 sm:grid-cols-2
  lg:grid-cols-3"). Widen it to xl:grid-cols-4 or drop to two columns — keep
  the featured card's span strictly below the column count so it never fills
  a whole row unintentionally.
- Featured layout: remove sm:flex-row to keep the featured card vertical and
  merely double-width, or raise the span for a full-bleed lead story.
- Density: titleLines / excerptLines are the density dial. titleLines={0}
  shows full titles (cards stay aligned through the mt-auto byline);
  excerptLines={0} lets the longest excerpt drive the card height.
- Cover ratio: swap aspect-video for aspect-[4/3] or aspect-square; the
  featured card's sm:min-h-56 is the only fixed floor and can go.
- Meta row: drop the avatar for a text-only byline, or add a bookmark
  control — anything interactive must carry "relative z-10", or the
  stretched link swallows its clicks.
- Cover fallback: replace the ImageOff tile with initials, a category colour
  wash or a blurhash canvas without touching the link structure.

Concepts

  • Stretched link, not a wrapping anchor — the clickable surface is the title link's ::after stretched over a relative card. Wrapping the whole card in an <a> would nest the category and author anchors inside it: invalid HTML, and a screen reader's link list turns to noise.
  • Lifted secondary links — the category chip and the author name carry relative z-10, which puts them above the stretched overlay. That is what makes "click the card" and "click the category" two different destinations instead of one.
  • Deliberate clamp — truncation is a number you pass (titleLines, excerptLines, 0 to disable), not a fixed height with overflow:hidden. The title box also reserves <n>lh, so a one-word headline and a twenty-word headline leave their excerpts and bylines on the same baseline.
  • A featured span needs a known column count — double width only means something when the grid has at least two tracks, so the columns come from breakpoints rather than auto-fill; col-span-2 inside a one-column auto grid invents an extra track and pushes the card off-screen.
  • Cover degradation — the frame keeps its ratio whether or not the image arrives, onError swaps in an icon tile, and a ref probe catches images that already failed before hydration could attach the handler.
  • Contract-supplied reading time, UTC dates — minutes come from the data instead of a word-count guess, and the visible date is formatted in UTC while <time dateTime> keeps the raw ISO string, so a date-only post does not slip a day for readers west of Greenwich.

On This Page