# Blog Grid

> Recent posts as a responsive grid of post cards, with a headline and a link to the full archive.

- **Slug:** `blog-grid`
- **Kind:** section · **Category:** Features
- **Source:** original to Viewership UI
- **Docs page:** https://viewership.ai/design/components/blog-grid
- **All files in one bundle:** https://viewership.ai/design/components/blog-grid.bundle.txt
- **Structured JSON:** https://viewership.ai/design/components/blog-grid.json

## Install

1. Import the foundation tokens once (download: https://viewership.ai/design/raw/foundation.css.txt):

```astro
---
// src/layouts/Layout.astro
import "../styles/foundation.css";
---
```

2. Copy this file into `src/components/viewership/blog-grid/`:

   - `BlogGrid.astro` (raw: https://viewership.ai/design/raw/blog-grid/BlogGrid.astro.txt)

3. Copy its registry dependencies, each into its own sibling folder under `src/components/viewership/`:

   - `post-card`: `PostCard.astro` (docs: https://viewership.ai/design/components/post-card.md)

npm packages: none.

## Usage

```astro
---
import BlogGrid from "../components/viewership/blog-grid/BlogGrid.astro";
---

<BlogGrid />
```

## Example

The gallery demo, verbatim. Use the import path from Usage instead of `./`.

```astro
---
import BlogGrid from "./BlogGrid.astro";
---

<BlogGrid
  title="Notes from the"
  accent="field."
  all={{ label: "All posts", href: "#" }}
  posts={[
    {
      href: "#",
      title: "What 40 Reddit threads taught us about first-time hikers",
      excerpt: "The questions people ask before they buy a pack.",
      author: "Field team",
      readTime: "5 min read",
      tag: "Research",
      date: "2026-03-09",
      image: { src: "/ui/demo/ridgeline.webp", alt: "Layered mountain ridges at dusk", width: 1600, height: 1000 },
    },
    {
      href: "#",
      title: "How to rewax a canvas pack in ten minutes",
      excerpt: "One bar of wax, a hair dryer and an old towel.",
      author: "Repairs desk",
      readTime: "4 min read",
      tag: "Repairs",
      date: "2026-02-21",
      image: { src: "/ui/demo/contours.webp", alt: "Topographic contour map", width: 1600, height: 1100 },
    },
    {
      href: "#",
      title: "Night routes worth the early start",
      excerpt: "Three ridgelines that look best before sunrise.",
      author: "Trips team",
      readTime: "6 min read",
      tag: "Trips",
      date: "2026-02-02",
      image: { src: "/ui/demo/ridgeline-night.webp", alt: "Mountain ridges under a full moon", width: 1600, height: 1000 },
    },
  ]}
/>
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `title` | `string` |  | Headline. |
| `accent` | `string` |  | Closing words of the headline, drawn in the theme's accent style. |
| `body` | `string` |  | Short line under the headline. |
| `posts` | `PostCard props[]` |  | The posts, newest first. Each takes the same props as Post Card. |
| `all` | `{ label: string; href: string }` |  | Link to the archive, beside the headline. |
| `as` | `"h1" \| "h2"` | `"h2"` | Heading element. Card titles step down one level. |

## Source

### `src/components/viewership/blog-grid/BlogGrid.astro`

```astro
---
/**
 * BlogGrid: a section of recent posts. Headline and an optional "view all"
 * link on top, then one to three columns of PostCards depending on the width
 * of the container.
 *
 * @example
 * <BlogGrid title="From the" accent="journal." all={{ label: "All posts", href: "/journal" }} posts={posts} />
 */
import PostCard from "../post-card/PostCard.astro";

interface Image {
  src: string;
  alt: string;
  width?: number;
  height?: number;
}
interface Post {
  href: string;
  title: string;
  excerpt?: string;
  author?: string;
  readTime?: string;
  tag?: string;
  date?: string | Date;
  image?: Image;
}
interface Link {
  label: string;
  href: string;
}
interface Props {
  title: string;
  accent?: string;
  body?: string;
  posts: Post[];
  /** Link to the full archive, set beside the headline. */
  all?: Link;
  as?: "h1" | "h2";
  class?: string;
}

const { title, accent, body, posts, all, as: Heading = "h2", class: className } = Astro.props;
const cardHeading = Heading === "h1" ? "h2" : "h3";
---

<section class:list={["vs-blog-grid", className]}>
  <div class="vs-blog-grid__inner">
    <div class="vs-blog-grid__head">
      <div>
        <Heading class="vs-display vs-blog-grid__title">{title}{accent && <> <em>{accent}</em></>}</Heading>
        {body && <p class="vs-blog-grid__body">{body}</p>}
      </div>
      {
        all && (
          <a class="vs-blog-grid__all" href={all.href}>
            {all.label}
            <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6" /></svg>
          </a>
        )
      }
    </div>
    <ul class="vs-blog-grid__list">
      {
        posts.map((p) => (
          <li>
            <PostCard {...p} as={cardHeading} />
          </li>
        ))
      }
    </ul>
  </div>
</section>

<style>
  .vs-blog-grid {
    container-type: inline-size;
    background: var(--vs-page);
    color: var(--vs-ink);
    font-family: var(--vs-font-sans);
    font-stretch: var(--vs-font-stretch);
  }
  .vs-blog-grid__inner {
    max-width: var(--vs-container);
    margin-inline: auto;
    padding: var(--vs-section-y) var(--vs-gutter);
  }
  .vs-blog-grid__head {
    display: flex;
    flex-wrap: wrap;
    align-items: flex-end;
    justify-content: space-between;
    gap: 16px 32px;
    margin-bottom: clamp(28px, 5cqi, 48px);
  }
  .vs-blog-grid__title {
    font-size: clamp(2rem, 5.5cqi, 3.5rem);
  }
  .vs-blog-grid__body {
    max-width: 32rem;
    margin: 1rem 0 0;
    font-size: 1.0625rem;
    line-height: 1.55;
    color: var(--vs-ink-2);
  }
  .vs-blog-grid__all {
    display: inline-flex;
    align-items: center;
    gap: 8px;
    padding: 4px 0;
    color: var(--vs-ink);
    font-size: 0.9375rem;
    font-weight: 500;
    text-decoration: none;
    border-radius: var(--vs-radius-chip);
  }
  .vs-blog-grid__all:focus-visible {
    outline: none;
    box-shadow: var(--vs-focus-ring);
  }
  .vs-blog-grid__all svg {
    width: 18px;
    height: 18px;
    fill: none;
    stroke: currentColor;
    stroke-width: 2;
    stroke-linecap: round;
    stroke-linejoin: round;
    transition: transform var(--vs-duration) var(--vs-ease-out);
  }
  .vs-blog-grid__all:hover svg {
    transform: translateX(3px);
  }
  .vs-blog-grid__list {
    display: grid;
    gap: 20px;
    margin: 0;
    padding: 0;
    list-style: none;
  }
  .vs-blog-grid__list > li {
    display: grid;
  }
  @container (min-width: 620px) {
    .vs-blog-grid__list {
      grid-template-columns: repeat(2, minmax(0, 1fr));
    }
  }
  @container (min-width: 960px) {
    .vs-blog-grid__list {
      grid-template-columns: repeat(3, minmax(0, 1fr));
      gap: 24px;
    }
  }
</style>
```
