Skip to content
txbrendan/ui

Post Grid

beta

A 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 Raman
  • 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 Novak
  • Company, 2 min read

    We opened a second office

    More room for the team, and a place to meet the people we work with.

    Marcus Bell
  • Product, 5 min read

    Working offline

    Edits made without a connection now save and sync when you are back online.

    Dev Kapoor
  • Company, 5 min read

    Our first year

    What we shipped, what we learned, and what comes next.

    Lucía Moreno

three posts under a post, no filter

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

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 Raman

Install

  1. 1. Add the registry to components.json, once per app. Without it, the shadcn CLI looks up @txbrendan in a public list that txbrendan/ui does not control.

    npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"
  2. 2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.

    npx shadcn@latest add @txbrendan/theme
  3. 3. Add the website component. It also adds every component it uses. Its own files go in your components folder, 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

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" />