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
73 changes: 73 additions & 0 deletions app/components/demo/FeatureGridDemo.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<script setup lang="ts">
import type { Component } from "vue";
import { Blocks, ChartNoAxesColumnIncreasing, Files, Route, Search, ShieldCheck } from "lucide-vue-next";
import { FeatureGrid, type FeatureGridItem } from "~/registry/blocks/feature-grid";

const features: FeatureGridItem[] = [
{
id: "blocks",
title: "Composable blocks",
description: "Ship complete sections without moving product copy, route decisions, or data ownership into the registry item.",
},
{
id: "content",
title: "App-owned content",
description: "Pass the exact feature list your product needs, from static arrays, CMS records, or localized copy.",
},
{
id: "routing",
title: "Route-ready links",
description: "Link selected features to deeper docs or marketing pages while preserving your app's navigation model.",
href: "/docs/components",
},
{
id: "analytics",
title: "Analytics friendly",
description: "Keep click tracking and campaign metadata in your app instead of coupling the block to one analytics tool.",
},
{
id: "search",
title: "Discovery surfaces",
description: "Use the same data shape for landing pages, onboarding screens, docs overviews, and searchable feature indexes.",
},
{
id: "security",
title: "Safe external links",
description: "Blank-target feature links receive a safe default rel while still allowing app-specific rel overrides.",
href: "https://ui.stackhacker.io/r/feature-grid.json",
target: "_blank",
},
];

const icons: Partial<Record<string, Component>> = {
blocks: Blocks,
content: Files,
routing: Route,
analytics: ChartNoAxesColumnIncreasing,
search: Search,
security: ShieldCheck,
};

function featureIcon(item: FeatureGridItem) {
return item.id ? icons[item.id] : undefined;
}
</script>

<template>
<FeatureGrid
headline="Feature sections"
title="A complete grid for product capabilities"
description="Render a polished feature section from app-owned data while keeping icons, routes, analytics, and content sources under your control."
:items="features"
class="py-0"
>
<template #icon="{ item }">
<component
:is="featureIcon(item)"
v-if="featureIcon(item)"
class="size-5"
aria-hidden="true"
/>
</template>
</FeatureGrid>
</template>
146 changes: 146 additions & 0 deletions app/registry/blocks/feature-grid/FeatureGrid.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { computed } from "vue";
import { cn } from "@/lib/utils";

export interface FeatureGridItem {
/** Stable id for rendering. Falls back to title when omitted. */
id?: string;
/** Feature title supplied by the consuming app. */
title: string;
/** Supporting feature copy supplied by the consuming app. */
description?: string;
/** Optional destination. When omitted, the item renders as static content. */
href?: string;
/** Anchor target used when href is supplied. */
target?: HTMLAnchorElement["target"];
/** Anchor rel used when href is supplied. */
rel?: string;
}

export interface FeatureGridProps {
/** Optional eyebrow label shown above the section title. */
headline?: string;
/** Optional section title. */
title?: string;
/** Optional section description. */
description?: string;
/** Feature items supplied by the consuming app. */
items?: FeatureGridItem[];
/** Responsive column count for large screens. */
columns?: 2 | 3 | 4;
/** Additional CSS classes for the section. */
class?: HTMLAttributes["class"];
}

const props = withDefaults(defineProps<FeatureGridProps>(), {
items: () => [],
columns: 3,
});

const gridClass = computed(() => {
return {
2: "lg:grid-cols-2",
3: "lg:grid-cols-3",
4: "lg:grid-cols-4",
}[props.columns];
});

function itemKey(item: FeatureGridItem) {
return item.id ?? item.title;
}

function linkRel(item: FeatureGridItem) {
if (item.rel) return item.rel;
if (item.target === "_blank") return "noopener noreferrer";
return undefined;
}
</script>

<template>
<section
data-slot="feature-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="headline || title || description"
data-slot="feature-grid-header"
class="mx-auto max-w-3xl text-center"
>
<p
v-if="headline"
data-slot="feature-grid-headline"
class="text-sm font-medium text-primary"
>
{{ headline }}
</p>

<h2
v-if="title"
data-slot="feature-grid-title"
class="mt-3 text-balance text-3xl font-semibold tracking-tight sm:text-4xl"
>
{{ title }}
</h2>

<p
v-if="description"
data-slot="feature-grid-description"
class="mt-4 text-pretty text-base/7 text-muted-foreground sm:text-lg/8"
>
{{ description }}
</p>
</div>

<div
data-slot="feature-grid-items"
:class="cn(
'grid gap-4 sm:grid-cols-2',
gridClass,
(headline || title || description) && 'mt-10 md:mt-12',
)"
>
<component
:is="item.href ? 'a' : 'article'"
v-for="item in items"
:key="itemKey(item)"
data-slot="feature-grid-item"
:href="item.href"
:target="item.href ? item.target : undefined"
:rel="item.href ? linkRel(item) : undefined"
:class="cn(
'group rounded-2xl border bg-card/70 p-5 text-card-foreground shadow-sm sm:p-6',
item.href && 'block transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
)"
>
<div
v-if="$slots.icon"
data-slot="feature-grid-item-icon"
class="mb-5 flex size-10 items-center justify-center rounded-xl bg-primary/10 text-primary transition-colors group-hover:bg-primary/15"
>
<slot
name="icon"
:item="item"
/>
</div>

<h3
data-slot="feature-grid-item-title"
class="text-balance text-base font-semibold tracking-tight"
>
{{ item.title }}
</h3>

<p
v-if="item.description"
data-slot="feature-grid-item-description"
class="mt-2 text-pretty text-sm/6 text-muted-foreground transition-colors group-hover:text-accent-foreground/80"
>
{{ item.description }}
</p>
</component>
</div>
</div>
</section>
</template>
1 change: 1 addition & 0 deletions app/registry/blocks/feature-grid/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as FeatureGrid, type FeatureGridItem, type FeatureGridProps } from "./FeatureGrid.vue";
125 changes: 125 additions & 0 deletions content/docs/2.components/14.feature-grid.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
---
title: FeatureGrid
description: Render a complete feature section from app-owned feature data.
category: marketing
---

::component-preview
---
name: FeatureGridDemo
---
::

## Installation

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

## Usage

```vue
<script setup lang="ts">
import type { Component } from 'vue'
import { Blocks, Route, Search } from 'lucide-vue-next'
import { FeatureGrid, type FeatureGridItem } from '@/components/feature-grid'

const features: FeatureGridItem[] = [
{
id: 'blocks',
title: 'Composable blocks',
description: 'Ship complete sections while keeping product data in your app.'
},
{
id: 'routing',
title: 'Route-ready links',
description: 'Link selected features to deeper pages without coupling the block to Nuxt routing.',
href: '/features'
},
{
id: 'search',
title: 'Discovery surfaces',
description: 'Use the same data shape for landing pages, docs overviews, and searchable indexes.'
}
]

const icons: Partial<Record<string, Component>> = {
blocks: Blocks,
routing: Route,
search: Search
}

function featureIcon(item: FeatureGridItem) {
return item.id ? icons[item.id] : undefined
}
</script>

<template>
<FeatureGrid
headline="Feature sections"
title="Product capabilities at a glance"
description="Render a polished feature section from app-owned data."
:items="features"
>
<template #icon="{ item }">
<component v-if="featureIcon(item)" :is="featureIcon(item)" class="size-5" aria-hidden="true" />
</template>
</FeatureGrid>
</template>
```

## FeatureGrid vs FeatureCard

`FeatureGrid` owns the full marketing section: optional headline, title, description, responsive grid layout, and repeated feature items. Use it when you want to install a complete section that can drop into a landing page, docs overview, or product page.

`FeatureCard` remains the smaller card primitive for one-off feature, content, or navigation cards. Use it when your app already owns the surrounding section layout or when you need to compose custom grids by hand.

## App-Owned Feature Data

Your app owns the `items` array, route generation, analytics events, active states, CMS mapping, localization, and icon rendering. The component intentionally does not accept Nuxt Icon string names, require `NuxtLink`, or install an icon package.

Pass icon markup through the scoped `icon` slot. That keeps the block portable across Lucide, Nuxt Icon, custom SVGs, or no icons at all.

When an item has `href`, it renders as an anchor. When `href` is omitted, it renders static article content. For `target="_blank"`, the component adds `rel="noopener noreferrer"` unless you provide a custom `rel` value.

## Examples

### Default

::component-preview
---
name: FeatureGridDemo
---
::

## API Reference

### Props

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `headline` | `string` | — | Optional eyebrow label shown above the section title. |
| `title` | `string` | — | Optional section title. |
| `description` | `string` | — | Optional section description. |
| `items` | `FeatureGridItem[]` | `[]` | Feature items supplied by your app. |
| `columns` | `2 \| 3 \| 4` | `3` | Responsive column count for large screens. |
| `class` | `string` | — | Additional CSS classes for the section. |

### Types

```ts
interface FeatureGridItem {
id?: string
title: string
description?: string
href?: string
target?: HTMLAnchorElement['target']
rel?: string
}
```

### Slots

| Slot | Props | Description |
|------|-------|-------------|
| `icon` | `{ item: FeatureGridItem }` | Optional icon markup rendered above each item title. |
4 changes: 4 additions & 0 deletions scripts/registry-verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,10 @@ const expectedItems: Record<string, ExpectedRegistryItem> = {
dependencies: [],
registryDependencies: [],
},
"feature-grid": {
dependencies: [],
registryDependencies: [],
},
"changelog-timeline": {
dependencies: [],
registryDependencies: [],
Expand Down
Loading