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
47 changes: 47 additions & 0 deletions app/components/demo/MetricsGridDemo.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<script setup lang="ts">
import { MetricsGrid, type MetricsGridItem } from "~/registry/blocks/metrics-grid";

const metrics: MetricsGridItem[] = [
{
id: "activation",
value: "42%",
label: "Activation lift",
description: "More teams reach their first successful workflow in week one.",
tone: "primary",
},
{
id: "response-time",
value: "< 90s",
label: "Median response",
description: "Support queues stay fast during product launches and migrations.",
tone: "success",
},
{
id: "accounts",
value: "12k+",
label: "Accounts monitored",
description: "Usage, billing, and health signals roll into one operator view.",
tone: "warning",
},
{
id: "retention",
value: "98.4%",
label: "Enterprise retention",
description: "Contract renewals stay predictable with proactive account alerts.",
tone: "muted",
},
];
</script>

<template>
<div class="rounded-xl border bg-muted/20 p-4 sm:p-6">
<MetricsGrid
headline="Growth signals"
title="A compact proof section for product momentum"
description="Show formatted metrics from your app without moving analytics, formatting, or tracking behavior into the block."
:items="metrics"
variant="cards"
class="py-0"
/>
</div>
</template>
137 changes: 137 additions & 0 deletions app/registry/blocks/metrics-grid/MetricsGrid.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { computed } from "vue";
import { cn } from "@/lib/utils";

export interface MetricsGridItem {
/** Stable metric id. Falls back to label and index when omitted. */
id?: string;
/** App-formatted display value */
value: string;
/** Metric label supplied by the consuming app */
label: string;
/** Optional supporting metric copy */
description?: string;
/** Constrained visual tone for the metric accent */
tone?: "default" | "primary" | "success" | "warning" | "muted";
}

export interface MetricsGridProps {
/** Optional eyebrow shown above the title */
headline?: string;
/** Optional section title */
title?: string;
/** Optional supporting section copy */
description?: string;
/** Metrics supplied by the consuming app */
items?: MetricsGridItem[] | null;
/** Maximum column count on large screens */
columns?: 2 | 3 | 4;
/** Visual treatment for each metric */
variant?: "default" | "cards";
/** Additional CSS classes for the section */
class?: HTMLAttributes["class"];
}

const props = withDefaults(defineProps<MetricsGridProps>(), {
items: () => [],
columns: 4,
variant: "default",
});

const metricItems = computed(() => props.items ?? []);
const hasContent = computed(() => Boolean(props.headline || props.title || props.description || metricItems.value.length));

function itemKey(item: MetricsGridItem, index: number) {
return item.id ?? `${item.label}-${index}`;
}

function gridColumnsClass() {
if (props.columns === 2) return "sm:grid-cols-2";
if (props.columns === 3) return "sm:grid-cols-2 lg:grid-cols-3";
return "sm:grid-cols-2 lg:grid-cols-4";
}

function toneClass(tone: MetricsGridItem["tone"]) {
if (tone === "primary") return "text-primary before:bg-primary";
if (tone === "success") return "text-emerald-600 before:bg-emerald-500 dark:text-emerald-400";
if (tone === "warning") return "text-amber-600 before:bg-amber-500 dark:text-amber-400";
if (tone === "muted") return "text-muted-foreground before:bg-muted-foreground/40";
return "text-foreground before:bg-border";
}
</script>

<template>
<section
v-if="hasContent"
data-slot="metrics-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="metrics-grid-header"
class="mx-auto max-w-3xl text-center"
>
<p
v-if="headline"
class="text-sm font-medium text-primary"
>
{{ headline }}
</p>
<h2
v-if="title"
class="mt-3 text-balance text-3xl font-semibold tracking-tight sm:text-4xl"
>
{{ title }}
</h2>
<p
v-if="description"
class="mt-4 text-pretty text-base leading-7 text-muted-foreground"
>
{{ description }}
</p>
</div>

<dl
v-if="metricItems.length"
data-slot="metrics-grid-list"
:class="cn(
'grid gap-4',
headline || title || description ? 'mt-10' : '',
gridColumnsClass(),
)"
>
<div
v-for="(item, index) in metricItems"
:key="itemKey(item, index)"
data-slot="metrics-grid-item"
:class="cn(
'relative min-w-0 overflow-hidden rounded-2xl border bg-card p-6 text-card-foreground',
variant === 'cards' ? 'shadow-sm' : 'bg-card/60',
)"
>
<dt class="text-sm leading-6 text-muted-foreground">
{{ item.label }}
</dt>
<dd class="mt-3 min-w-0">
<p
:class="cn(
'relative break-words ps-4 text-3xl font-semibold tracking-tight before:absolute before:left-0 before:top-1 before:h-8 before:w-1 before:rounded-full sm:text-4xl',
toneClass(item.tone),
)"
>
{{ item.value }}
</p>
<p
v-if="item.description"
class="mt-3 text-sm leading-6 text-muted-foreground"
>
{{ item.description }}
</p>
</dd>
</div>
</dl>
</div>
</section>
</template>
1 change: 1 addition & 0 deletions app/registry/blocks/metrics-grid/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as MetricsGrid, type MetricsGridItem, type MetricsGridProps } from "./MetricsGrid.vue";
97 changes: 97 additions & 0 deletions content/docs/2.components/13.metrics-grid.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
---
title: MetricsGrid
description: Render a responsive marketing metrics section from app-owned data.
category: marketing
---

::component-preview
---
name: MetricsGridDemo
---
::

## Installation

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

## Usage

```vue
<script setup lang="ts">
import { MetricsGrid, type MetricsGridItem } from '@/components/metrics-grid'

const metrics: MetricsGridItem[] = [
{
value: '42%',
label: 'Activation lift',
description: 'More teams reach their first successful workflow.',
tone: 'primary'
},
{
value: '< 90s',
label: 'Median response',
description: 'Support queues stay fast during launches.',
tone: 'success'
},
{
value: '12k+',
label: 'Accounts monitored',
description: 'Usage and health signals roll into one view.',
tone: 'warning'
}
]
</script>

<template>
<MetricsGrid
headline="Growth signals"
title="A compact proof section for product momentum"
description="Show formatted metrics from your app without moving analytics into the block."
:items="metrics"
/>
</template>
```

## App-Owned Metrics

`MetricsGrid` renders the values you pass in. Your app owns data loading, number formatting, localization, analytics, animation, and any metric definitions.

Pass formatted strings such as `42%`, `< 90s`, or `12k+` through `items[].value`. Use `tone` for constrained visual emphasis instead of passing raw Tailwind classes through metric data.

## Examples

### Cards

::component-preview
---
name: MetricsGridDemo
---
::

## API Reference

### Props

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `headline` | `string` | — | Optional eyebrow shown above the title. |
| `title` | `string` | — | Optional section title. |
| `description` | `string` | — | Optional supporting section copy. |
| `items` | `MetricsGridItem[] \| null` | `[]` | Metrics supplied by your app. |
| `columns` | `2 \| 3 \| 4` | `4` | Maximum column count on large screens. |
| `variant` | `'default' \| 'cards'` | `'default'` | Visual treatment for metric cards. |
| `class` | `string` | — | Additional CSS classes for the section. |

### Types

```ts
interface MetricsGridItem {
id?: string
value: string
label: string
description?: string
tone?: 'default' | 'primary' | 'success' | 'warning' | 'muted'
}
```
4 changes: 4 additions & 0 deletions scripts/registry-verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,10 @@ const expectedItems: Record<string, ExpectedRegistryItem> = {
dependencies: [],
registryDependencies: [],
},
"metrics-grid": {
dependencies: [],
registryDependencies: [],
},
};

if (registryIndex) {
Expand Down
Loading