Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions app/components/demo/BlogPostGridDemo.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<script setup lang="ts">
import { BlogPostGrid, type BlogPostGridPost } from "~/registry/blocks/blog-post-grid";

const posts: BlogPostGridPost[] = [
{
id: "design-process",
to: "/docs/components",
title: "From mockup to market: a practical design process",
description: "A field guide to moving from research and wireframes into shippable Nuxt interfaces without losing product context.",
dateLabel: "Apr 23, 2026",
badge: "Design",
readTime: "8 min read",
image: {
src: "https://images.unsplash.com/photo-1497366754035-f200968a6e72?q=80&w=1200&auto=format&fit=crop",
alt: "A bright workspace with design notes and a laptop",
},
authors: [
{
name: "Emma Thompson",
avatar: {
src: "https://images.unsplash.com/photo-1494790108377-be9c29b29330?q=80&w=128&auto=format&fit=crop",
alt: "Emma Thompson",
},
},
],
},
{
id: "color-systems",
href: "https://ui.stackhacker.io/docs/components",
target: "_blank",
title: "Color systems that survive implementation",
description: "How semantic tokens keep visual decisions portable across marketing pages, docs, and product surfaces.",
dateLabel: "Mar 15, 2026",
badge: "Systems",
image: {
src: "https://images.unsplash.com/photo-1513542789411-b6a5d4f31634?q=80&w=1200&auto=format&fit=crop",
alt: "Color swatches and design materials",
},
},
{
id: "slow-design",
to: "/docs/components/feature-grid",
title: "The case for slow design in fast interfaces",
description: "Designing moments of pause can help content-heavy products feel more intentional and easier to scan.",
dateLabel: "Jan 28, 2026",
badge: "UX",
readTime: "7 min read",
},
];
</script>

<template>
<div class="rounded-xl border bg-muted/20 p-4 sm:p-6">
<BlogPostGrid
title="Latest articles"
description="Render display-ready post cards while your app owns routes, dates, images, authors, and content loading."
:posts="posts"
class="py-0"
/>
</div>
</template>
217 changes: 217 additions & 0 deletions app/registry/blocks/blog-post-grid/BlogPostGrid.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { computed } from "vue";
import { cn } from "@/lib/utils";

export interface BlogPostGridImage {
/** Image source supplied by the consuming app. */
src: string;
/** Accessible image alt text. */
alt?: string;
}

export interface BlogPostGridAuthor {
/** Visible author name. */
name: string;
/** Optional author avatar image. */
avatar?: BlogPostGridImage;
}

export interface BlogPostGridPost {
/** Stable id for rendering. Falls back to title and index when omitted. */
id?: string;
/** Internal Nuxt route. */
to?: string;
/** External or plain anchor href. */
href?: string;
/** Anchor target used when href is supplied. */
target?: HTMLAnchorElement["target"];
/** Anchor rel used when href is supplied. */
rel?: string;
/** Post title. */
title: string;
/** Supporting excerpt supplied by the consuming app. */
description?: string;
/** App-formatted date label. */
dateLabel?: string;
/** Optional badge or category label. */
badge?: string;
/** Optional read-time label. */
readTime?: string;
/** Optional post image. */
image?: BlogPostGridImage;
/** Optional authors supplied by the consuming app. */
authors?: BlogPostGridAuthor[];
}

export interface BlogPostGridProps {
/** Optional section title. */
title?: string;
/** Optional supporting section copy. */
description?: string;
/** Display-ready posts supplied by the consuming app. */
posts?: BlogPostGridPost[] | null;
/** Feature the first card on medium and larger screens. */
featuredFirst?: boolean;
/** Additional CSS classes for the section. */
class?: HTMLAttributes["class"];
}

const props = withDefaults(defineProps<BlogPostGridProps>(), {
posts: () => [],
featuredFirst: true,
});

const postItems = computed(() => props.posts ?? []);
const hasHeader = computed(() => Boolean(props.title || props.description));
const hasContent = computed(() => hasHeader.value || postItems.value.length > 0);

function postKey(post: BlogPostGridPost, index: number) {
return post.id ?? post.to ?? post.href ?? `${post.title}-${index}`;
}

function linkRel(post: BlogPostGridPost) {
if (post.rel) return post.rel;
if (post.target === "_blank") return "noopener noreferrer";
return undefined;
}

function linkComponent(post: BlogPostGridPost) {
if (post.to) return "NuxtLink";
if (post.href) return "a";
return "article";
}

function metaItems(post: BlogPostGridPost) {
return [post.badge, post.dateLabel, post.readTime].filter(Boolean);
}
</script>

<template>
<section
v-if="hasContent"
data-slot="blog-post-grid"
:class="cn('py-12 md:py-16', props.class)"
>
<div class="mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8">
<div
v-if="hasHeader"
data-slot="blog-post-grid-header"
class="mx-auto max-w-3xl text-center"
>
<h2
v-if="title"
data-slot="blog-post-grid-title"
class="text-balance text-3xl font-semibold tracking-tight sm:text-4xl"
>
{{ title }}
</h2>
<p
v-if="description"
data-slot="blog-post-grid-description"
class="mt-4 text-pretty text-base/7 text-muted-foreground sm:text-lg/8"
>
{{ description }}
</p>
</div>

<div
v-if="postItems.length"
data-slot="blog-post-grid-posts"
:class="cn(
'grid gap-5 md:grid-cols-2 lg:grid-cols-3',
hasHeader && 'mt-10 md:mt-12',
)"
>
<component
:is="linkComponent(post)"
v-for="(post, index) in postItems"
:key="postKey(post, index)"
data-slot="blog-post-grid-post"
:to="post.to"
:href="post.href"
:target="post.href ? post.target : undefined"
:rel="post.href ? linkRel(post) : undefined"
:class="cn(
'group overflow-hidden rounded-2xl border bg-card/70 text-card-foreground transition-colors',
(post.to || post.href) && 'block hover:bg-card focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
featuredFirst && index === 0 && 'md:col-span-2 md:grid md:grid-cols-2 lg:col-span-3',
)"
>
<div
v-if="post.image?.src"
data-slot="blog-post-grid-post-image"
:class="cn(
'aspect-video overflow-hidden bg-muted',
featuredFirst && index === 0 && 'md:h-full',
)"
>
<img
:src="post.image.src"
:alt="post.image.alt ?? post.title"
class="size-full object-cover transition-transform duration-300 group-hover:scale-105"
>
</div>

<div
data-slot="blog-post-grid-post-body"
class="p-6"
>
<div
v-if="metaItems(post).length"
data-slot="blog-post-grid-post-meta"
class="flex flex-wrap items-center gap-2 text-sm text-muted-foreground"
>
<span
v-for="item in metaItems(post)"
:key="item"
class="rounded-full bg-secondary px-2.5 py-1 text-xs font-medium text-secondary-foreground first:bg-primary/10 first:text-primary"
>
{{ item }}
</span>
</div>

<h3
data-slot="blog-post-grid-post-title"
:class="cn(
'text-pretty text-xl font-semibold tracking-tight group-hover:text-primary',
metaItems(post).length && 'mt-4',
featuredFirst && index === 0 && 'md:text-3xl',
)"
>
{{ post.title }}
</h3>

<p
v-if="post.description"
data-slot="blog-post-grid-post-description"
class="mt-3 line-clamp-3 text-sm/6 text-muted-foreground"
>
{{ post.description }}
</p>

<div
v-if="post.authors?.length"
data-slot="blog-post-grid-post-authors"
class="mt-5 flex flex-wrap gap-3"
>
<div
v-for="author in post.authors"
:key="author.name"
class="flex items-center gap-2 text-sm text-muted-foreground"
>
<img
v-if="author.avatar?.src"
:src="author.avatar.src"
:alt="author.avatar.alt ?? author.name"
class="size-6 rounded-full object-cover"
>
<span class="text-foreground">{{ author.name }}</span>
</div>
</div>
</div>
</component>
</div>
</div>
</section>
</template>
1 change: 1 addition & 0 deletions app/registry/blocks/blog-post-grid/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as BlogPostGrid, type BlogPostGridAuthor, type BlogPostGridImage, type BlogPostGridPost, type BlogPostGridProps } from "./BlogPostGrid.vue";
111 changes: 111 additions & 0 deletions content/docs/2.components/16.blog-post-grid.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
---
title: BlogPostGrid
description: Render responsive blog and content teaser grids from display-ready post data.
category: content
---

::component-preview
---
name: BlogPostGridDemo
---
::

## Installation

```bash
npx shadcn-vue@latest add "https://ui.stackhacker.io/r/blog-post-grid.json"
```

## Usage

```vue
<script setup lang="ts">
import { BlogPostGrid, type BlogPostGridPost } from '@/components/blog-post-grid'

const posts: BlogPostGridPost[] = [
{
id: 'design-process',
to: '/blog/design-process',
title: 'From mockup to market',
description: 'A field guide to moving from research into shippable interfaces.',
dateLabel: 'Apr 23, 2026',
badge: 'Design',
readTime: '8 min read',
image: {
src: '/images/design-process.jpg',
alt: 'Design notes and a laptop'
},
authors: [
{ name: 'Emma Thompson' }
]
}
]
</script>

<template>
<BlogPostGrid
title="Latest articles"
description="Render display-ready post cards while your app owns routing and formatting."
:posts="posts"
/>
</template>
```

## App-Owned Post Data

`BlogPostGrid` renders display-ready data. Your app owns fetching, sorting, slicing, localization, date formatting, route construction, image policy, and analytics.

Pass formatted strings such as `Apr 23, 2026` through `dateLabel`. The component does not call `new Date()`, generate routes from slugs, import global post data, or install icon/button/avatar dependencies.

Use `to` for internal Nuxt routes and `href` for external links. For `target="_blank"`, the component adds `rel="noopener noreferrer"` unless you provide a custom `rel` value.

## Examples

### Featured first post

::component-preview
---
name: BlogPostGridDemo
---
::

## API Reference

### Props

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `title` | `string` | — | Optional section title. |
| `description` | `string` | — | Optional supporting section copy. |
| `posts` | `BlogPostGridPost[] \| null` | `[]` | Display-ready post data supplied by your app. |
| `featuredFirst` | `boolean` | `true` | Feature the first card on medium and larger screens. |
| `class` | `string` | — | Additional CSS classes for the section. |

### Types

```ts
interface BlogPostGridImage {
src: string
alt?: string
}

interface BlogPostGridAuthor {
name: string
avatar?: BlogPostGridImage
}

interface BlogPostGridPost {
id?: string
to?: string
href?: string
target?: HTMLAnchorElement['target']
rel?: string
title: string
description?: string
dateLabel?: string
badge?: string
readTime?: string
image?: BlogPostGridImage
authors?: BlogPostGridAuthor[]
}
```
Loading
Loading