Release Timeline
A dated changelog on a vertical rail with added/changed/fixed tags and machine-readable <time> values.
A featured post at full width above a card grid, with drawn covers built from tokens — a blog index with no image pipeline and no broken-thumbnail state.
components/blog-post-grid.tsxnpx hoverlab add blog-post-grid
Or over MCP, from your editor's agent — no account needed.
Free to read, copy and install, for personal and non-commercial projects. Shipping it in client work or a paid product needs Pro ($79 once). The source lands in your repo and stops being ours — no attribution, nothing to upgrade.
Start a page with this section — add more, order them, and leave with the page source.
Blameless is easy to say and hard to schedule. The template, the timebox, and the one question we ask before anyone opens a dashboard.
Six weeks, zero visual regressions, one codemod we should have written first.
At 1.8B rows the vacuum stopped keeping up. What we tried, in order.
Turns out "Contact us" was doing a lot of damage for a two-word button.
A written design doc predicts on-the-job performance better than a weekend project.
Rendered live in your current theme — this is the same component whose source is below, not a screenshot of it.
/**
* <BlogPostGrid> — a blog index with one featured post and a grid of the rest.
*
* Every index page has one post the team actually wants read this week; a
* uniform grid buries it at position one, same size as position six. Here
* the featured post gets a full-width card with a drawn cover, and only
* then does the grid start — so the hierarchy is in the layout, not in an
* editor's hope that visitors read left to right.
*
* The whole card is the link. A title-only link leaves the cover and the
* excerpt as dead pixels that look clickable and are not — the most common
* blog-card papercut.
*/
import * as React from 'react'
import { Clock } from 'lucide-react'
export interface BlogPost {
slug: string
category: string
title: string
excerpt: string
author: string
/** ISO date — `2026-07-08`. */
date: string
readMinutes: number
}
export interface BlogPostGridProps {
featured?: BlogPost
posts?: BlogPost[]
categories?: string[]
heading?: string
className?: string
}
const DEFAULT_FEATURED: BlogPost = {
slug: '/blog/incident-review-culture',
category: 'Engineering',
title: 'How we run incident reviews nobody dreads',
excerpt:
'Blameless is easy to say and hard to schedule. The template, the timebox, and the one question we ask before anyone opens a dashboard.',
author: 'Priya Raman',
date: '2026-07-08',
readMinutes: 9,
}
const DEFAULT_POSTS: BlogPost[] = [
{
slug: '/blog/design-tokens-migration',
category: 'Design',
title: 'Migrating 214 components to semantic tokens',
excerpt: 'Six weeks, zero visual regressions, one codemod we should have written first.',
author: 'Dana Whitfield',
date: '2026-06-24',
readMinutes: 7,
},
{
slug: '/blog/postgres-partitioning',
category: 'Engineering',
title: 'Partitioning the events table before it partitioned us',
excerpt: 'At 1.8B rows the vacuum stopped keeping up. What we tried, in order.',
author: 'Marco Silva',
date: '2026-06-10',
readMinutes: 11,
},
{
slug: '/blog/pricing-page-rewrite',
category: 'Product',
title: 'The pricing page rewrite that halved support tickets',
excerpt: 'Turns out "Contact us" was doing a lot of damage for a two-word button.',
author: 'Alex Chen',
date: '2026-05-28',
readMinutes: 5,
},
{
slug: '/blog/hiring-writing-samples',
category: 'Company',
title: 'We stopped doing take-homes and asked for a doc instead',
excerpt: 'A written design doc predicts on-the-job performance better than a weekend project.',
author: 'Samir Haddad',
date: '2026-05-14',
readMinutes: 6,
},
]
const DEFAULT_CATEGORIES = ['All posts', 'Engineering', 'Design', 'Product', 'Company']
export function BlogPostGrid({
featured = DEFAULT_FEATURED,
posts = DEFAULT_POSTS,
categories = DEFAULT_CATEGORIES,
heading = 'From the blog',
className = '',
}: BlogPostGridProps) {
return (
<section className={`mx-auto w-full max-w-7xl px-4 py-16 sm:px-6 lg:px-8 ${className}`}>
<div className="mb-8 flex flex-wrap items-end justify-between gap-4">
<h2 className="text-balance text-3xl font-bold tracking-tight sm:text-4xl">{heading}</h2>
<ul className="flex flex-wrap gap-2">
{categories.map((category, i) => (
<li key={category}>
<a
href="#"
aria-current={i === 0 ? 'page' : undefined}
className={
i === 0
? 'inline-block rounded-full bg-primary px-3 py-1 text-xs font-semibold text-primary-foreground'
: 'inline-block rounded-full border border-border/60 bg-background px-3 py-1 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground'
}
>
{category}
</a>
</li>
))}
</ul>
</div>
<a
href={featured.slug}
className="group mb-5 grid overflow-hidden rounded-2xl border border-border/60 bg-card/80 transition-shadow hover:shadow-lg md:grid-cols-2"
>
<div
aria-hidden
className="relative min-h-48 bg-gradient-to-br from-primary/25 via-primary/10 to-muted md:min-h-full"
>
<div className="absolute inset-x-8 bottom-0 top-10 rounded-t-xl border border-b-0 border-border/60 bg-background/80 p-4">
<div className="h-2 w-1/3 rounded bg-primary/40 border border-transparent" />
<div className="mt-3 h-2 w-3/4 rounded bg-muted-foreground/25 border border-transparent" />
<div className="mt-2 h-2 w-2/3 rounded bg-muted-foreground/25 border border-transparent" />
<div className="mt-2 h-2 w-1/2 rounded bg-muted-foreground/15 border border-transparent" />
</div>
</div>
<div className="flex flex-col p-6 sm:p-8">
<span className="w-fit rounded-full bg-secondary px-2.5 py-0.5 text-xs font-semibold text-secondary-foreground">
{featured.category}
</span>
<h3 className="mt-4 text-balance text-2xl font-bold tracking-tight transition-colors group-hover:text-primary sm:text-3xl">
{featured.title}
</h3>
<p className="mt-3 break-words text-sm leading-relaxed text-muted-foreground">{featured.excerpt}</p>
<PostMeta post={featured} className="mt-auto pt-6" />
</div>
</a>
<ul className="grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
{posts.map((post) => (
<li key={post.slug}>
<a
href={post.slug}
className="group flex h-full flex-col rounded-2xl border border-border/60 bg-card/80 p-5 transition-shadow hover:shadow-lg"
>
<span className="w-fit rounded-full bg-muted px-2.5 py-0.5 text-xs font-semibold text-muted-foreground">
{post.category}
</span>
<h3 className="mt-3 break-words text-balance font-bold tracking-tight transition-colors group-hover:text-primary">
{post.title}
</h3>
<p className="mt-2 break-words text-sm leading-relaxed text-muted-foreground">{post.excerpt}</p>
<PostMeta post={post} className="mt-auto pt-4" />
</a>
</li>
))}
</ul>
</section>
)
}
function PostMeta({ post, className = '' }: { post: BlogPost; className?: string }) {
return (
<div className={`flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground ${className}`}>
<span className="font-medium text-foreground/80">{post.author}</span>
<time dateTime={post.date}>
{new Date(`${post.date}T00:00:00Z`).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
timeZone: 'UTC',
})}
</time>
<span className="inline-flex items-center gap-1">
<Clock aria-hidden className="h-3 w-3" />
{post.readMinutes} min read
</span>
</div>
)
}
bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.Drop it at components/blog-post-grid.tsx and import it where you need the section:
import { BlogPostGrid } from '@/components/blog-post-grid'1 of this block’s props are simple enough to drive from here. Change them and the block below re-renders — it is the same component whose source is above, not a mock of it. Everything else it accepts is in the table underneath.
Read out of the component’s own type and signature, so this cannot drift from the source below. Every prop has a default — the component renders standalone before you pass it anything.
| Prop | Type | Default |
|---|---|---|
featured | BlogPost | DEFAULT_FEATURED |
posts | BlogPost[] | DEFAULT_POSTS |
categories | string[] | DEFAULT_CATEGORIES |
heading | string | 'From the blog' |
className | string | '' |
The same block rendered once to markup, wrapped as a file your framework compiles. Tailwind classes are framework-agnostic, so the design transfers intact — the behaviour does not.
<!--
Blog Index Grid — markup from the Hoverlab catalog.
This is the block rendered once to HTML and wrapped as a component
file. It is not a port of the React source: the Tailwind classes carry
the design, which is the part that took the work, and they are the same
in every framework.
This block has no interactive behaviour, so nothing is missing.
-->
<section class="mx-auto w-full max-w-7xl px-4 py-16 sm:px-6 lg:px-8 ">
<div class="mb-8 flex flex-wrap items-end justify-between gap-4">
<h2 class="text-balance text-3xl font-bold tracking-tight sm:text-4xl">From the blog</h2>
<ul class="flex flex-wrap gap-2">
<li>
<a href="#" aria-current="page" class="inline-block rounded-full bg-primary px-3 py-1 text-xs font-semibold text-primary-foreground">All posts</a>
</li>
<li>
<a href="#" class="inline-block rounded-full border border-border/60 bg-background px-3 py-1 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground">Engineering</a>
</li>
<li>
<a href="#" class="inline-block rounded-full border border-border/60 bg-background px-3 py-1 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground">Design</a>
</li>
<li>
<a href="#" class="inline-block rounded-full border border-border/60 bg-background px-3 py-1 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground">Product</a>
</li>
<li>
<a href="#" class="inline-block rounded-full border border-border/60 bg-background px-3 py-1 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground">Company</a>
</li>
</ul>
</div>
<a href="/blog/incident-review-culture" class="group mb-5 grid overflow-hidden rounded-2xl border border-border/60 bg-card/80 transition-shadow hover:shadow-lg md:grid-cols-2">
<div aria-hidden="true" class="relative min-h-48 bg-gradient-to-br from-primary/25 via-primary/10 to-muted md:min-h-full">
<div class="absolute inset-x-8 bottom-0 top-10 rounded-t-xl border border-b-0 border-border/60 bg-background/80 p-4">
<div class="h-2 w-1/3 rounded bg-primary/40 border border-transparent"></div>
<div class="mt-3 h-2 w-3/4 rounded bg-muted-foreground/25 border border-transparent"></div>
<div class="mt-2 h-2 w-2/3 rounded bg-muted-foreground/25 border border-transparent"></div>
<div class="mt-2 h-2 w-1/2 rounded bg-muted-foreground/15 border border-transparent"></div>
</div>
</div>
<div class="flex flex-col p-6 sm:p-8">
<span class="w-fit rounded-full bg-secondary px-2.5 py-0.5 text-xs font-semibold text-secondary-foreground">Engineering</span>
<h3 class="mt-4 text-balance text-2xl font-bold tracking-tight transition-colors group-hover:text-primary sm:text-3xl">How we run incident reviews nobody dreads</h3>
<p class="mt-3 break-words text-sm leading-relaxed text-muted-foreground">Blameless is easy to say and hard to schedule. The template, the timebox, and the one question we ask before anyone opens a dashboard.</p>
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground mt-auto pt-6">
<span class="font-medium text-foreground/80">Priya Raman</span>
<time dateTime="2026-07-08">Jul 8, 2026</time>
<span class="inline-flex items-center gap-1"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-clock h-3 w-3" aria-hidden="true"><path d="M12 6v6l4 2"></path><circle cx="12" cy="12" r="10"></circle></svg>9 min read</span>
</div>
</div>
</a>
<ul class="grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
<li>
<a href="/blog/design-tokens-migration" class="group flex h-full flex-col rounded-2xl border border-border/60 bg-card/80 p-5 transition-shadow hover:shadow-lg">
<span class="w-fit rounded-full bg-muted px-2.5 py-0.5 text-xs font-semibold text-muted-foreground">Design</span>
<h3 class="mt-3 break-words text-balance font-bold tracking-tight transition-colors group-hover:text-primary">Migrating 214 components to semantic tokens</h3>
<p class="mt-2 break-words text-sm leading-relaxed text-muted-foreground">Six weeks, zero visual regressions, one codemod we should have written first.</p>
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground mt-auto pt-4">
<span class="font-medium text-foreground/80">Dana Whitfield</span>
<time dateTime="2026-06-24">Jun 24, 2026</time>
<span class="inline-flex items-center gap-1"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-clock h-3 w-3" aria-hidden="true"><path d="M12 6v6l4 2"></path><circle cx="12" cy="12" r="10"></circle></svg>7 min read</span>
</div>
</a>
</li>
<li>
<a href="/blog/postgres-partitioning" class="group flex h-full flex-col rounded-2xl border border-border/60 bg-card/80 p-5 transition-shadow hover:shadow-lg">
<span class="w-fit rounded-full bg-muted px-2.5 py-0.5 text-xs font-semibold text-muted-foreground">Engineering</span>
<h3 class="mt-3 break-words text-balance font-bold tracking-tight transition-colors group-hover:text-primary">Partitioning the events table before it partitioned us</h3>
<p class="mt-2 break-words text-sm leading-relaxed text-muted-foreground">At 1.8B rows the vacuum stopped keeping up. What we tried, in order.</p>
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground mt-auto pt-4">
<span class="font-medium text-foreground/80">Marco Silva</span>
<time dateTime="2026-06-10">Jun 10, 2026</time>
<span class="inline-flex items-center gap-1"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-clock h-3 w-3" aria-hidden="true"><path d="M12 6v6l4 2"></path><circle cx="12" cy="12" r="10"></circle></svg>11 min read</span>
</div>
</a>
</li>
<li>
<a href="/blog/pricing-page-rewrite" class="group flex h-full flex-col rounded-2xl border border-border/60 bg-card/80 p-5 transition-shadow hover:shadow-lg">
<span class="w-fit rounded-full bg-muted px-2.5 py-0.5 text-xs font-semibold text-muted-foreground">Product</span>
<h3 class="mt-3 break-words text-balance font-bold tracking-tight transition-colors group-hover:text-primary">The pricing page rewrite that halved support tickets</h3>
<p class="mt-2 break-words text-sm leading-relaxed text-muted-foreground">Turns out "Contact us" was doing a lot of damage for a two-word button.</p>
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground mt-auto pt-4">
<span class="font-medium text-foreground/80">Alex Chen</span>
<time dateTime="2026-05-28">May 28, 2026</time>
<span class="inline-flex items-center gap-1"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-clock h-3 w-3" aria-hidden="true"><path d="M12 6v6l4 2"></path><circle cx="12" cy="12" r="10"></circle></svg>5 min read</span>
</div>
</a>
</li>
<li>
<a href="/blog/hiring-writing-samples" class="group flex h-full flex-col rounded-2xl border border-border/60 bg-card/80 p-5 transition-shadow hover:shadow-lg">
<span class="w-fit rounded-full bg-muted px-2.5 py-0.5 text-xs font-semibold text-muted-foreground">Company</span>
<h3 class="mt-3 break-words text-balance font-bold tracking-tight transition-colors group-hover:text-primary">We stopped doing take-homes and asked for a doc instead</h3>
<p class="mt-2 break-words text-sm leading-relaxed text-muted-foreground">A written design doc predicts on-the-job performance better than a weekend project.</p>
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground mt-auto pt-4">
<span class="font-medium text-foreground/80">Samir Haddad</span>
<time dateTime="2026-05-14">May 14, 2026</time>
<span class="inline-flex items-center gap-1"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-clock h-3 w-3" aria-hidden="true"><path d="M12 6v6l4 2"></path><circle cx="12" cy="12" r="10"></circle></svg>6 min read</span>
</div>
</a>
</li>
</ul>
</section>
What each framework gets across the whole catalog — effects convert properly; this rung is markup.
The component, its props, the design tokens it expects and the command that installs it — as one prompt. Paste it into Claude, Cursor, v0 or ChatGPT and what they build around it will match the rest of the catalog instead of inventing its own system.
Want the whole screen instead of this one section? Open a page and copy it entire.
All of them free to read, copy and install — no account, no locked tiles, no watermarked preview. The whole catalog is open, and so are the API and the CLI.
Browse Content & BlogCopying the code is free. Putting it in client work or a paid product is what Pro is for — the licence, not the access.
A dated changelog on a vertical rail with added/changed/fixed tags and machine-readable <time> values.
Shipped / in progress / planned in three columns, with status carried by icon as well as colour.
A grid of case-study cards that lead with the metric rather than the company name, each one linking out to the full write-up — the block a customer-stories section or a home page needed and only had a full case-study page for.
Kicker, headline, standfirst, byline and share row, with the opening prose and a pull quote included — the type scale of a post, decided once instead of per-article.
An about-page team section on initials avatars rather than photos, so shipping it never waits on headshots and there is no broken-image state to design.
Openings grouped by department with location, type and salary on every row — candidates self-select by craft first, and a flat list makes them scan for it.
Sidebar, article and on-this-page rail — each column answers a different question, and they collapse on mobile in the order that keeps the article alive.
Method badge, path, parameter table and a request/response pair kept side by side — an example request whose response is a scroll away gets pasted blind.
One install command behind npm/pnpm/yarn/bun tabs with a working copy button — every reader uses exactly one package manager, so showing all four wastes three lines.
A CV as a document rather than a timeline of cards — one column, dates on the end edge, and a print layer that puts it on paper without a separate PDF to keep in step.
The watch screen with no playback in it: a ratio-locked stage, a control bar drawn down to the buffered layer, and an up-next rail. Drop a video element in and wire your own player.