Post Grid
betaA grid of blog posts, each one link with a cover, a tag, a title, an excerpt, a date, and its author, with an optional row of tags to narrow them.
Built from Avatar, Button, Panel Switch, and Tabs.
Preview
Product, 4 min read
Shared folders are here
Put files in one folder that the whole team can open, and choose who can change them.
Priya RamanDesign, 6 min read
How we review designs each week
One meeting, a short list, and notes that stay on the design.
Elin SatoProduct, 3 min read
Search now looks inside files
Find a document by a word in it, not only by its name.
Tomás OkaforEngineering, 9 min read
Making sync faster on large teams
What we changed so a team of a thousand sees edits in under a second.
Hana NovakCompany, 2 min read
We opened a second office
More room for the team, and a place to meet the people we work with.
Marcus BellDesign, 7 min read
Picking colors that pass contrast in light and dark
How we check every pair of colors before it ships.
Ana LindqvistProduct, 5 min read
Working offline
Edits made without a connection now save and sync when you are back online.
Dev KapoorEngineering, 8 min read
Running our tests in half the time
Splitting the slow tests, and running only the ones a change can reach.
Sam Achebe
three posts under a post, no filter
Design, 6 min read
How we review designs each week
One meeting, a short list, and notes that stay on the design.
Elin SatoProduct, 3 min read
Search now looks inside files
Find a document by a word in it, not only by its name.
Tomás OkaforEngineering, 9 min read
Making sync faster on large teams
What we changed so a team of a thousand sees edits in under a second.
Hana Novak
one post
Product, 4 min read
Shared folders are here
Put files in one folder that the whole team can open, and choose who can change them.
Priya Raman
a long title, no excerpt, no author
Engineering, 9 min read
Making sync faster on large teams
What we changed so a team of a thousand sees edits in under a second.
Hana NovakDesign, 7 min read
Picking colors that pass contrast in light and dark
How we check every pair of colors before it ships.
Ana Lindqvist
a tag with no posts offers Show all
No posts yet.
no posts yet
No posts yet.
narrow container: one column
Product, 4 min read
Shared folders are here
Put files in one folder that the whole team can open, and choose who can change them.
Priya RamanDesign, 6 min read
How we review designs each week
One meeting, a short list, and notes that stay on the design.
Elin SatoProduct, 3 min read
Search now looks inside files
Find a document by a word in it, not only by its name.
Tomás Okafor
Install
1. Add the registry to
components.json, once per app. Without it, the shadcn CLI looks up@txbrendanin a public list that txbrendan/ui does not control.npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.
npx shadcn@latest add @txbrendan/theme3. Add the website component. It also adds every component it uses. Its own files go in your
componentsfolder, and they are yours to edit.npx shadcn@latest add @txbrendan/post-grid
Use it for
- A blog's index page: every post, newest first, with `featured` to draw the newest wide across the top and `filter` to narrow by tag.
- The end of a post: three more posts, no filter, under a Headline like "More posts".
- Give each post a `cover`, like an <img>. Without one it gets a soft field of dots in its `tone`, so the grid still looks finished.
- On the post's own page, pass `<PostGridCover post={post} />` to Article's `cover`, so the reader sees the same cover they pressed.
- Pass your router's link through `renderLink`, like next/link, so posts open client-side.
- Set `headingLevel` to 2 when the grid sits right under the page's h1, and keep 3 under an h2.
- Control the tag with `value` and `onValueChange` when the address holds it, like /blog?tag=design.
Avoid it for
- Feature sections. Bento Grid shows what a product does, with live previews, and its cards need no date or author. Post Grid lists dated posts by people.
- Quotes from customers. Use Testimonial Wall.
- Release notes by version. Use a changelog.
- More than one tag per post. The filter shows one tag per post.
- Hundreds of posts on one page. Split them into pages with Pagination under the grid.
Props
- posts
The posts, newest first. Each href must be unique, since it is the key. A post with a tag joins the filter under that tag.
Post[]
- Post
Type. One post. `date` is an ISO date, like "2026-03-04", shown as "Mar 4, 2026". `readingTime` is words, like "6 min read". `tone` colors the default cover, default "neutral".
{ href: string; title: ReactNode; excerpt?: ReactNode; tag?: string; date: string; readingTime?: string; author?: { name: string; avatar?: string }; cover?: ReactNode; tone?: "neutral" | "brand" | "danger" | "success" | "warning" }
- featured
Draws the first post wide across the top, with its cover beside the words from 48rem, while every post shows.
boolean · default false
- filter
Shows a row of tags over the grid, All first, then each tag in the order the posts bring them. The grid slides to the picked tag's posts.
boolean · default false
- value
The tag shown, or null for every post. Pass it to control the filter.
string | null
- defaultValue
The tag shown first, when the filter is not controlled.
string | null · default null
- onValueChange
Called with the tag picked, or null for All.
(value: string | null) => void
- renderLink
Draws each post's link. Spread the props onto your router's link: (props) => <Link {...props} />.
(props: PostGridLinkProps) => ReactNode · default a plain <a>
- headingLevel
The level of each post's title, so it fits under the heading above it.
2 | 3 | 4 · default 3
- locale
The language the dates are written in.
string · default "en-US"
- allLabel
The first tag, which shows every post.
string · default "All"
- filterLabel
The name screen readers give the row of tags.
string · default "Filter posts by tag"
- emptyLabel
What shows when no post is left to show.
ReactNode · default "No posts yet."
- showAllLabel
The button under the empty line while a tag is picked. It goes back to every post.
string · default "Show all"
- className
Extra classes on the root, which holds the container query.
string
| Prop | Type | Default | What it does |
|---|---|---|---|
| posts | Post[] | - | The posts, newest first. Each href must be unique, since it is the key. A post with a tag joins the filter under that tag. |
| Post | { href: string; title: ReactNode; excerpt?: ReactNode; tag?: string; date: string; readingTime?: string; author?: { name: string; avatar?: string }; cover?: ReactNode; tone?: "neutral" | "brand" | "danger" | "success" | "warning" } | - | Type. One post. `date` is an ISO date, like "2026-03-04", shown as "Mar 4, 2026". `readingTime` is words, like "6 min read". `tone` colors the default cover, default "neutral". |
| featured | boolean | false | Draws the first post wide across the top, with its cover beside the words from 48rem, while every post shows. |
| filter | boolean | false | Shows a row of tags over the grid, All first, then each tag in the order the posts bring them. The grid slides to the picked tag's posts. |
| value | string | null | - | The tag shown, or null for every post. Pass it to control the filter. |
| defaultValue | string | null | null | The tag shown first, when the filter is not controlled. |
| onValueChange | (value: string | null) => void | - | Called with the tag picked, or null for All. |
| renderLink | (props: PostGridLinkProps) => ReactNode | a plain <a> | Draws each post's link. Spread the props onto your router's link: (props) => <Link {...props} />. |
| headingLevel | 2 | 3 | 4 | 3 | The level of each post's title, so it fits under the heading above it. |
| locale | string | "en-US" | The language the dates are written in. |
| allLabel | string | "All" | The first tag, which shows every post. |
| filterLabel | string | "Filter posts by tag" | The name screen readers give the row of tags. |
| emptyLabel | ReactNode | "No posts yet." | What shows when no post is left to show. |
| showAllLabel | string | "Show all" | The button under the empty line while a tag is picked. It goes back to every post. |
| className | string | - | Extra classes on the root, which holds the container query. |
Accessibility
- The posts are a list, and each one is an article with its title as a heading at `headingLevel`.
- Each post is one link: the title, stretched over the whole post. Screen readers hear one link per post, named by its title.
- A focused post outlines the whole card.
- The date is a <time> with the ISO date, and the avatar is hidden, since the name is written beside it.
- The default cover is hidden from screen readers. Give an <img> cover an empty alt, since the title already says what the post is.
- The row of tags is a tablist named by `filterLabel`. The arrow keys move between tags, and the grid is its panel.
- While the grid slides, the posts leaving are inert, so nothing in them takes focus or a press.
- For a moment after a switch, a press on a post that just slid in under the pointer opens nothing, so a double click never opens a post by mistake.
- In forced colors each cover gets a border, so its edge still shows when its fill is painted away.
Examples
The same cover on the post's own page
<Article
title="Shared folders are here"
date="2026-10-28"
tag="Product"
cover={<PostGridCover post={{ href: "/blog/shared-folders", tag: "Product", tone: "brand" }} />}
>
<p>Put files in one folder that the whole team can open.</p>
</Article>A blog index with a featured post and a tag filter
<PostGrid
featured
filter
headingLevel={2}
posts={[
{
href: "/blog/shared-folders",
title: "Shared folders are here",
excerpt: "Put files in one folder that the whole team can open.",
tag: "Product",
date: "2026-10-28",
readingTime: "4 min read",
author: { name: "Priya Raman" },
tone: "brand",
},
{
href: "/blog/review-designs",
title: "How we review designs each week",
tag: "Design",
date: "2026-10-21",
author: { name: "Elin Sato", avatar: "/people/elin.jpg" },
cover: <img src="/covers/review.jpg" alt="" />,
},
]}
/>More posts at the end of a post, with next/link
<PostGrid
posts={morePosts.slice(0, 3)}
renderLink={(props) => <Link {...props} />}
/>A filter that follows the page's address
const [tag, setTag] = useState<string | null>(searchParams.get("tag"));
<PostGrid
filter
posts={posts}
value={tag}
onValueChange={(next) => {
setTag(next);
router.replace(next ? `/blog?tag=${encodeURIComponent(next)}` : "/blog", { scroll: false });
}}
/>Dates in German
<PostGrid posts={posts} locale="de-DE" allLabel="Alle" filter filterLabel="Beiträge nach Thema filtern" />