# Post Card

> Blog card with an inset image, byline, title and arrow, excerpt, tag and date. The whole card is a single link.

- **Slug:** `post-card`
- **Kind:** component · **Category:** Content
- **Source:** original to Viewership UI
- **Docs page:** https://viewership.ai/design/components/post-card
- **All files in one bundle:** https://viewership.ai/design/components/post-card.bundle.txt
- **Structured JSON:** https://viewership.ai/design/components/post-card.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/post-card/`:

   - `PostCard.astro` (raw: https://viewership.ai/design/raw/post-card/PostCard.astro.txt)

npm packages: none.

## Usage

```astro
---
import PostCard from "../components/viewership/post-card/PostCard.astro";
---

<PostCard />
```

## Example

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

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

<div class="stage">
  <PostCard
    href="#"
    title="What our weekly report learned from 40 Reddit threads"
    excerpt="The questions hikers ask before they buy, and where AI answers send them."
    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 }}
  />
</div>

<style>
  .stage {
    display: grid;
    place-items: center;
    padding: 48px 20px;
    border-radius: var(--vs-radius-window);
    background: var(--vs-canvas);
  }
  .stage > :global(*) {
    width: min(100%, 380px);
  }
</style>
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `href` | `string` |  | Post URL. The title link is stretched over the whole card. |
| `title` | `string` |  | Post title. |
| `excerpt` | `string` |  | One or two sentences under the title. |
| `author` | `string` |  | Byline name. |
| `readTime` | `string` |  | Shown after the author, e.g. "5 min read". |
| `tag` | `string` |  | Category pill in the footer. |
| `date` | `string \| Date` |  | ISO date or Date, rendered in a <time> element. |
| `image` | `{ src; alt; width?; height? }` |  | Cover image, cropped to 3:2. |
| `as` | `"h2" \| "h3" \| "h4"` | `"h3"` | Heading element for the title. |
| `slot: media` | `slot` |  | Replaces the image (video, illustration). |

## Used by

[Blog Grid](https://viewership.ai/design/components/blog-grid.md)

## Source

### `src/components/viewership/post-card/PostCard.astro`

```astro
---
/**
 * PostCard: a blog or journal card. Inset image, a byline, the title with an
 * arrow, a short excerpt, then a tag and the date. The whole card is one link
 * (the title's link is stretched over it), so screen readers hear one name.
 *
 * @example
 * <PostCard href="/journal/waxing" title="How to rewax a canvas pack" excerpt="Ten minutes, one bar of wax." author="Field team" readTime="5 min read" tag="Repairs" date="2026-03-09" image={{ src: "/pack.webp", alt: "" }} />
 */

interface Image {
  src: string;
  alt: string;
  width?: number;
  height?: number;
}
interface Props {
  href: string;
  title: string;
  excerpt?: string;
  author?: string;
  readTime?: string;
  tag?: string;
  /** ISO date (2026-03-09) or a Date. Rendered in a <time>. */
  date?: string | Date;
  image?: Image;
  as?: "h2" | "h3" | "h4";
  class?: string;
}

const { href, title, excerpt, author, readTime, tag, date, image, as: Heading = "h3", class: className } = Astro.props;

const d = date ? (date instanceof Date ? date : new Date(`${date}T00:00:00`)) : null;
const iso = d ? d.toISOString().slice(0, 10) : undefined;
const pretty = d ? d.toLocaleDateString("en-US", { month: "short", day: "2-digit", year: "numeric" }) : undefined;
const hasMedia = image || Astro.slots.has("media");
---

<article class:list={["vs-post-card", className]}>
  {
    hasMedia && (
      <div class="vs-post-card__media">
        <slot name="media">{image && <img src={image.src} alt={image.alt} width={image.width} height={image.height} loading="lazy" />}</slot>
      </div>
    )
  }
  {
    (author || readTime) && (
      <p class="vs-post-card__byline">
        {author && <span>{author}</span>}
        {author && readTime && <span class="vs-post-card__dot" aria-hidden="true" />}
        {readTime && <span>{readTime}</span>}
      </p>
    )
  }
  <div class="vs-post-card__head">
    <Heading class="vs-post-card__title">
      <a href={href}>{title}</a>
    </Heading>
    <svg class="vs-post-card__arrow" viewBox="0 0 24 24" aria-hidden="true"><path d="M7 17 17 7M7 7h10v10" /></svg>
  </div>
  {excerpt && <p class="vs-post-card__excerpt">{excerpt}</p>}
  {
    (tag || d) && (
      <p class="vs-post-card__foot">
        {tag && <span class="vs-post-card__tag">{tag}</span>}
        {d && <time datetime={iso}>{pretty}</time>}
      </p>
    )
  }
</article>

<style>
  .vs-post-card {
    position: relative;
    display: flex;
    flex-direction: column;
    padding: 14px 14px 20px;
    border-radius: calc(var(--vs-radius-window) + 6px);
    background: var(--vs-surface);
    color: var(--vs-ink);
    font-family: var(--vs-font-sans);
    font-stretch: var(--vs-font-stretch);
    border: var(--vs-border-w) solid var(--vs-elev-ring);
    box-shadow: var(--vs-pop-shadow, var(--vs-shadow-sm));
    transition:
      transform var(--vs-duration) var(--vs-ease-out),
      box-shadow var(--vs-duration) var(--vs-ease-out);
  }
  .vs-post-card:hover {
    transform: translateY(-3px);
    box-shadow: var(--vs-pop-shadow, var(--vs-shadow-lg));
  }
  .vs-post-card:focus-within {
    box-shadow: var(--vs-focus-ring);
  }
  .vs-post-card__media {
    aspect-ratio: 3 / 2;
    overflow: hidden;
    border-radius: var(--vs-radius-window);
    background: var(--vs-canvas);
  }
  .vs-post-card__media :global(:is(img, video)) {
    display: block;
    width: 100%;
    height: 100%;
    object-fit: cover;
    transition: transform 600ms var(--vs-ease-out);
  }
  .vs-post-card:hover .vs-post-card__media :global(:is(img, video)) {
    transform: scale(1.03);
  }
  .vs-post-card__byline {
    display: flex;
    align-items: center;
    gap: 10px;
    margin: 18px 6px 0;
    font-size: 0.875rem;
    color: var(--vs-ink-3);
  }
  .vs-post-card__dot {
    width: 4px;
    height: 4px;
    border-radius: 50%;
    background: currentColor;
    opacity: 0.6;
  }
  .vs-post-card__head {
    display: flex;
    align-items: flex-start;
    justify-content: space-between;
    gap: 16px;
    margin: 10px 6px 0;
  }
  .vs-post-card__title {
    margin: 0;
    font-size: 1.1875rem;
    font-weight: 500;
    line-height: 1.35;
    letter-spacing: -0.01em;
    text-wrap: pretty;
  }
  .vs-post-card__title a {
    color: inherit;
    text-decoration: none;
    outline: none;
  }
  /* One link, the whole card. */
  .vs-post-card__title a::after {
    content: "";
    position: absolute;
    inset: 0;
    border-radius: inherit;
  }
  .vs-post-card__arrow {
    flex: none;
    width: 22px;
    height: 22px;
    margin-top: 2px;
    fill: none;
    stroke: var(--vs-ink-3);
    stroke-width: 2;
    stroke-linecap: round;
    stroke-linejoin: round;
    transition:
      transform var(--vs-duration) var(--vs-ease-out),
      stroke var(--vs-duration-fast) ease-out;
  }
  .vs-post-card:hover .vs-post-card__arrow {
    stroke: var(--vs-accent);
    transform: translate(2px, -2px);
  }
  .vs-post-card__excerpt {
    margin: 8px 6px 0;
    font-size: 1rem;
    line-height: 1.5;
    color: var(--vs-ink-2);
    text-wrap: pretty;
  }
  .vs-post-card__foot {
    display: flex;
    align-items: center;
    gap: 14px;
    margin: auto 6px 0;
    padding-top: 18px;
    font-size: 0.875rem;
    color: var(--vs-ink-3);
  }
  .vs-post-card__tag {
    padding: 6px 14px;
    border-radius: var(--vs-radius-pill);
    background: var(--vs-inset);
    border: var(--vs-border-w) solid var(--vs-line-soft);
    color: var(--vs-ink);
  }
</style>
```
