# Post Grid

> 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.

- Name: `post-grid`
- Status: beta
- Categories: blog, posts, articles, marketing
- Files: the install writes `post-grid.tsx`, and any part files named `post-grid-*.tsx`, to your `components` alias, `@/components` by default. Edit them freely. They are yours.
- Other registry items: `@arbokui/avatar`, `@arbokui/button`, `@arbokui/panel-switch`, `@arbokui/tabs`. The install adds them.
- Gallery: https://arbokui.com/website/post-grid
- Registry item: https://arbokui.com/r/post-grid.json

## Install

1. Add the registry to `components.json`. Do this first, once:

   ```bash
   npx shadcn@latest registry add "@arbokui=https://arbokui.com/r/{name}.json"
   ```

   The command writes this entry. You can also add it by hand:

   ```json
   {
     "registries": {
       "@arbokui": "https://arbokui.com/r/{name}.json"
     }
   }
   ```

   Without this step, the shadcn CLI looks up `@arbokui` in a public list that Arbokui does not control.

2. Add the theme once per app. It replaces your color tokens with ones checked for contrast:

   ```bash
   npx shadcn@latest add @arbokui/theme
   ```

3. Add the website component. It also adds every component it uses:

   ```bash
   npx shadcn@latest add @arbokui/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`: `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`. Default `false`. Draws the first post wide across the top, with its cover beside the words from 48rem, while every post shows.
- `filter`: `boolean`. Default `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`. Default `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`. Default `a plain <a>`. Draws each post's link. Spread the props onto your router's link: (props) => \<Link {...props} />.
- `headingLevel`: `2 | 3 | 4`. Default `3`. The level of each post's title, so it fits under the heading above it.
- `locale`: `string`. Default `"en-US"`. The language the dates are written in.
- `allLabel`: `string`. Default `"All"`. The first tag, which shows every post.
- `filterLabel`: `string`. Default `"Filter posts by tag"`. The name screen readers give the row of tags.
- `emptyLabel`: `ReactNode`. Default `"No posts yet."`. What shows when no post is left to show.
- `showAllLabel`: `string`. Default `"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

```tsx
<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

```tsx
<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

```tsx
<PostGrid
  posts={morePosts.slice(0, 3)}
  renderLink={(props) => <Link {...props} />}
/>
```

### A filter that follows the page's address

```tsx
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

```tsx
<PostGrid posts={posts} locale="de-DE" allLabel="Alle" filter filterLabel="Beiträge nach Thema filtern" />
```
