Blog
Blog Grid
Section header and a grid of post cards whose photos zoom on hover, optionally paged with Load more.
Layout and motion from the Nitip template — see it in context"use client";
import * as React from "react";
import Link from "next/link";
import { SpringButton } from "@/components/ui/spring-button";
import { ZoomCard } from "@/components/ui/zoom-card";
import { cn } from "@/lib/utils";
/*
* Blog Grid — a section header (title, action, divider, lead) and a grid of post cards, optionally paged with a
* "Load more" button.
* Source: marcframe/nitip → components/sections/BlogSection.tsx, MoreBlogSection.tsx and BlogArchiveSection.tsx
* (+ design-system SectionHeader, PostCard, PostGrid).
*
* Layout kept from Framer: one column below 768px, two from 768px, three from 1024px. Without paging it shows three
* posts, and a fourth between 768 and 1023px so the two-column grid stays even. With `pageSize` every post is
* rendered, the ones past the current page hidden (so each is linked in the HTML), the page being smaller below
* 1024px; "Load more" adds a page and disappears when everything shows. Cards: 320:187 photo, title, excerpt clamped
* to two lines from 768px (at every width when paged, so a row's cards match).
* Motion kept: the photo zooms to 110% while the card is hovered, with Framer's tween, cubic-bezier(0.12, 0.23, 0.5,
* 1) over 400ms (zoom-card). Each card is one link. Dropped: Nitip's "Read" cursor label.
*/
export type BlogPost = { title: string; excerpt: string; href: string; image: { src: string; alt: string } };
export type BlogGridProps = {
title?: string;
lead?: string;
/** Button beside the title; leave it out on an archive page. */
action?: { label: string; href: string };
posts?: BlogPost[];
/** Page by page with a "Load more" button: posts per page below and from 1024px. */
pageSize?: { small: number; large: number };
loadMoreLabel?: string;
className?: string;
};
const photo = (id: string) => `https://images.unsplash.com/${id}?auto=format&fit=crop&w=900&q=80`;
const defaults = {
title: "Blog",
lead: "Notes from the work: what moved the numbers, what didn't, and what we would do again.",
action: { label: "All posts", href: "/blog" },
posts: [
{
title: "How we plan a month of social content in one afternoon",
excerpt: "A simple calendar, three content pillars and a batch day — the routine that keeps small teams posting without burning out.",
href: "/blog/plan-a-month-of-content",
image: { src: photo("photo-1432888498266-38ffec3eaf0a"), alt: "Hands sketching app wireframes next to a phone" },
},
{
title: "Reading your analytics without drowning in them",
excerpt: "Five numbers worth checking every week, and the dozens you can safely ignore until something changes.",
href: "/blog/reading-your-analytics",
image: { src: photo("photo-1460925895917-afdab827c52f"), alt: "Laptop on a desk showing an analytics dashboard" },
},
{
title: "Writing ad copy people actually finish reading",
excerpt: "Short sentences, one promise and a clear next step. What we learned from testing four hundred variations.",
href: "/blog/ad-copy-people-read",
image: { src: photo("photo-1455390582262-044cdead277a"), alt: "Fountain pen writing on paper" },
},
{
title: "Why your brand needs fewer channels, not more",
excerpt: "Being everywhere spreads a small team thin. How to pick the two channels that matter and do them well.",
href: "/blog/fewer-channels",
image: { src: photo("photo-1519389950473-47ba0277781c"), alt: "Team working on laptops around a table, seen from above" },
},
] as BlogPost[],
};
export function BlogGrid({
title = defaults.title,
lead = defaults.lead,
action = defaults.action,
posts = defaults.posts,
pageSize,
loadMoreLabel = "Load more",
className,
}: BlogGridProps) {
const [pages, setPages] = React.useState(1);
const gridId = React.useId();
const smallCount = pageSize ? pages * pageSize.small : 4;
const largeCount = pageSize ? pages * pageSize.large : 3;
const shown = pageSize ? posts : posts.slice(0, 4);
const moreSmall = pageSize ? smallCount < posts.length : false;
const moreLarge = pageSize ? largeCount < posts.length : false;
return (
<section className={cn("py-(--section-y)", className)}>
<div className="mx-auto flex max-w-(--container) flex-col gap-12 px-(--gutter) md:gap-16 lg:gap-20">
<div className="flex flex-col gap-5">
<div className="flex items-center justify-between gap-5 md:items-end">
<h2 className="min-w-0 font-heading text-h2 font-semibold tracking-tight text-balance">{title}</h2>
{action && (
<SpringButton href={action.href} arrow={false}>
{action.label}
</SpringButton>
)}
</div>
<hr className="border-border" />
{lead && <p className="max-w-3xl self-end text-h3 font-medium text-pretty">{lead}</p>}
</div>
<div className="flex flex-col items-center gap-12 lg:gap-16">
<div id={gridId} className="grid w-full gap-8 md:grid-cols-2 md:gap-y-12 lg:grid-cols-3">
{shown.map((post, i) => (
<Link
key={post.href}
href={post.href}
className={cn(
"group flex flex-col gap-2.5 rounded-lg outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50",
pageSize
? cn(i >= smallCount && "max-lg:hidden", i >= largeCount && "lg:hidden")
: cn(i === 3 && "hidden md:max-lg:flex"),
)}
>
<ZoomCard zoom={1.1} className="aspect-[320/187]">
<img src={post.image.src} alt={post.image.alt} className="object-cover" />
</ZoomCard>
<h3 className="mt-1 font-heading text-h3 font-semibold tracking-tight text-balance">{post.title}</h3>
<p className={cn("text-muted-foreground", pageSize ? "line-clamp-2" : "md:line-clamp-2")}>{post.excerpt}</p>
</Link>
))}
</div>
{(moreSmall || moreLarge) && (
<SpringButton
type="button"
variant="outline"
arrow={false}
aria-controls={gridId}
onClick={() => setPages((n) => n + 1)}
className={cn(!moreSmall && "max-lg:hidden", !moreLarge && "lg:hidden")}
>
{loadMoreLabel}
</SpringButton>
)}
</div>
</div>
</section>
);
}Installation
$ npx shadcn@latest add https://framebrick.co/r/blocks/blog-grid.json1. Add the Framebrick UI components it uses (they bring Motion).
npx shadcn@latest add https://framebrick.co/r/spring-button.json https://framebrick.co/r/zoom-card.json2. Add the block tokens to
app/globals.cssonce (theming).npx shadcn@latest add https://framebrick.co/r/blocks/tokens.json3. Copy the block into
components/blog-grid.tsx."use client"; import * as React from "react"; import Link from "next/link"; import { SpringButton } from "@/components/ui/spring-button"; import { ZoomCard } from "@/components/ui/zoom-card"; import { cn } from "@/lib/utils"; /* * Blog Grid — a section header (title, action, divider, lead) and a grid of post cards, optionally paged with a * "Load more" button. * Source: marcframe/nitip → components/sections/BlogSection.tsx, MoreBlogSection.tsx and BlogArchiveSection.tsx * (+ design-system SectionHeader, PostCard, PostGrid). * * Layout kept from Framer: one column below 768px, two from 768px, three from 1024px. Without paging it shows three * posts, and a fourth between 768 and 1023px so the two-column grid stays even. With `pageSize` every post is * rendered, the ones past the current page hidden (so each is linked in the HTML), the page being smaller below * 1024px; "Load more" adds a page and disappears when everything shows. Cards: 320:187 photo, title, excerpt clamped * to two lines from 768px (at every width when paged, so a row's cards match). * Motion kept: the photo zooms to 110% while the card is hovered, with Framer's tween, cubic-bezier(0.12, 0.23, 0.5, * 1) over 400ms (zoom-card). Each card is one link. Dropped: Nitip's "Read" cursor label. */ export type BlogPost = { title: string; excerpt: string; href: string; image: { src: string; alt: string } }; export type BlogGridProps = { title?: string; lead?: string; /** Button beside the title; leave it out on an archive page. */ action?: { label: string; href: string }; posts?: BlogPost[]; /** Page by page with a "Load more" button: posts per page below and from 1024px. */ pageSize?: { small: number; large: number }; loadMoreLabel?: string; className?: string; }; const photo = (id: string) => `https://images.unsplash.com/${id}?auto=format&fit=crop&w=900&q=80`; const defaults = { title: "Blog", lead: "Notes from the work: what moved the numbers, what didn't, and what we would do again.", action: { label: "All posts", href: "/blog" }, posts: [ { title: "How we plan a month of social content in one afternoon", excerpt: "A simple calendar, three content pillars and a batch day — the routine that keeps small teams posting without burning out.", href: "/blog/plan-a-month-of-content", image: { src: photo("photo-1432888498266-38ffec3eaf0a"), alt: "Hands sketching app wireframes next to a phone" }, }, { title: "Reading your analytics without drowning in them", excerpt: "Five numbers worth checking every week, and the dozens you can safely ignore until something changes.", href: "/blog/reading-your-analytics", image: { src: photo("photo-1460925895917-afdab827c52f"), alt: "Laptop on a desk showing an analytics dashboard" }, }, { title: "Writing ad copy people actually finish reading", excerpt: "Short sentences, one promise and a clear next step. What we learned from testing four hundred variations.", href: "/blog/ad-copy-people-read", image: { src: photo("photo-1455390582262-044cdead277a"), alt: "Fountain pen writing on paper" }, }, { title: "Why your brand needs fewer channels, not more", excerpt: "Being everywhere spreads a small team thin. How to pick the two channels that matter and do them well.", href: "/blog/fewer-channels", image: { src: photo("photo-1519389950473-47ba0277781c"), alt: "Team working on laptops around a table, seen from above" }, }, ] as BlogPost[], }; export function BlogGrid({ title = defaults.title, lead = defaults.lead, action = defaults.action, posts = defaults.posts, pageSize, loadMoreLabel = "Load more", className, }: BlogGridProps) { const [pages, setPages] = React.useState(1); const gridId = React.useId(); const smallCount = pageSize ? pages * pageSize.small : 4; const largeCount = pageSize ? pages * pageSize.large : 3; const shown = pageSize ? posts : posts.slice(0, 4); const moreSmall = pageSize ? smallCount < posts.length : false; const moreLarge = pageSize ? largeCount < posts.length : false; return ( <section className={cn("py-(--section-y)", className)}> <div className="mx-auto flex max-w-(--container) flex-col gap-12 px-(--gutter) md:gap-16 lg:gap-20"> <div className="flex flex-col gap-5"> <div className="flex items-center justify-between gap-5 md:items-end"> <h2 className="min-w-0 font-heading text-h2 font-semibold tracking-tight text-balance">{title}</h2> {action && ( <SpringButton href={action.href} arrow={false}> {action.label} </SpringButton> )} </div> <hr className="border-border" /> {lead && <p className="max-w-3xl self-end text-h3 font-medium text-pretty">{lead}</p>} </div> <div className="flex flex-col items-center gap-12 lg:gap-16"> <div id={gridId} className="grid w-full gap-8 md:grid-cols-2 md:gap-y-12 lg:grid-cols-3"> {shown.map((post, i) => ( <Link key={post.href} href={post.href} className={cn( "group flex flex-col gap-2.5 rounded-lg outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50", pageSize ? cn(i >= smallCount && "max-lg:hidden", i >= largeCount && "lg:hidden") : cn(i === 3 && "hidden md:max-lg:flex"), )} > <ZoomCard zoom={1.1} className="aspect-[320/187]"> <img src={post.image.src} alt={post.image.alt} className="object-cover" /> </ZoomCard> <h3 className="mt-1 font-heading text-h3 font-semibold tracking-tight text-balance">{post.title}</h3> <p className={cn("text-muted-foreground", pageSize ? "line-clamp-2" : "md:line-clamp-2")}>{post.excerpt}</p> </Link> ))} </div> {(moreSmall || moreLarge) && ( <SpringButton type="button" variant="outline" arrow={false} aria-controls={gridId} onClick={() => setPages((n) => n + 1)} className={cn(!moreSmall && "max-lg:hidden", !moreLarge && "lg:hidden")} > {loadMoreLabel} </SpringButton> )} </div> </div> </section> ); }
Usage
import { BlogGrid } from "@/components/blog-grid";
// Home: the latest three posts
<BlogGrid posts={posts} action={{ label: "All posts", href: "/blog" }} />
// Archive: every post, a page at a time
<BlogGrid title="Blog" action={undefined} posts={posts} pageSize={{ small: 4, large: 6 }} />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| title / lead | string | — | Section heading (h2) and the large lead under the divider. |
| action | { label, href } | All posts | Button beside the title; pass undefined on an archive. |
| posts | { title, excerpt, href, image: { src, alt } }[] | 4 posts | Cards; each is one link with an h3 title. |
| pageSize | { small, large } | — | Paged mode: posts per page below and from 1024px, with a Load more button. |
| loadMoreLabel | string | Load more | Text of the Load more button. |
| className | string | — | Classes for the <section>. |
- One column below 768px, two from 768px, three from 1024px. Without pageSize it shows three posts (four between 768 and 1023px, so the grid stays even).
- With pageSize every post is in the HTML and the ones past the page are hidden; Load more adds a page and disappears when everything shows.
- Photos zoom to 110% while the card is hovered or focused (zoom-card, 400ms, cubic-bezier(0.12, 0.23, 0.5, 1)). Excerpts clamp to two lines from 768px — at every width when paged.
AI prompt
Paste into v0, Lovable, Bolt or Claude to rebuild this block in your own project.
Create a React + Tailwind CSS block called BlogGrid (Next.js).
- <section className="py-(--section-y)">, container mx-auto max-w-(--container) px-(--gutter), column gap-12 / md:gap-16 / lg:gap-20.
- Header: a row with an h2 (font-heading text-h2 semibold tracking-tight) and an optional shadcn-style button, an <hr>, a lead
(text-h3 font-medium) up to max-w-3xl aligned right.
- Grid: 1 / 2 (768px) / 3 (1024px) columns, gap-8 (md:gap-y-12). Card: one link (group) with a 320:187 photo in a rounded-lg frame
that zooms to 1.1 while the card is hovered or focused (400ms, cubic-bezier(0.12,0.23,0.5,1)), an h3 (font-heading text-h3
semibold) and a muted excerpt clamped to two lines from 768px.
- Default: show three posts, a fourth only between 768 and 1023px. Paged ({ small, large }): render every post, hide those past
page × small below 1024px and page × large from 1024px, and show an outline "Load more" button (aria-controls the grid) that adds
a page, hidden when nothing is left at that width.
- Only shadcn variables and block tokens; plain <img> with alt text.










