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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/blog-card-grid.jsonPrompt
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
::afterstretched over arelativecard. 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,0to disable), not a fixed height withoverflow: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-2inside 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,
onErrorswaps 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.
Contact Section
A contact block whose subject is the submit state machine — sending, confirmed, or failed with every character preserved — beside contact rows that derive their own mailto/tel/map links.
Auth Card
One card for sign-in, sign-up and reset, built around a typed rejection union — wrong credentials, address taken, rate limited — with correct autocomplete tokens per mode.
