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
88 changes: 88 additions & 0 deletions app/components/demo/FeatureSplitDemo.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
<script setup lang="ts">
import type { Component } from "vue";
import { Boxes, PanelsTopLeft, Sparkles, Workflow } from "lucide-vue-next";
import { FeatureSplit, type FeatureSplitFeature, type FeatureSplitSection } from "~/registry/blocks/feature-split";

const sections: FeatureSplitSection[] = [
{
id: "compose",
title: "Compose sections from product-owned content",
description: "Render a full split layout while your app keeps ownership of copy, media, icons, routing, and analytics.",
features: [
{
id: "slots",
title: "Slot-owned media",
description: "Bring screenshots, videos, SVGs, or custom cards without coupling the block to one asset pipeline.",
},
{
id: "features",
title: "Display-ready features",
description: "Pass the exact bullets your product needs from static data, CMS records, or localized strings.",
},
],
},
{
id: "ship",
title: "Alternate the story without rebuilding the layout",
description: "Use the same API for product walkthroughs, docs callouts, and marketing proof sections.",
reverse: true,
features: [
{
id: "portable",
title: "Portable by default",
description: "No Nuxt Icon strings, no local placeholder imports, and no runtime data fetching.",
},
{
id: "responsive",
title: "Responsive split rhythm",
description: "The section stacks naturally on mobile and switches to two columns on larger screens.",
},
],
},
];

const icons: Partial<Record<string, Component>> = {
slots: PanelsTopLeft,
features: Boxes,
portable: Workflow,
responsive: Sparkles,
};

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

<template>
<FeatureSplit
title="Feature split sections"
description="A complete split layout for product stories with app-owned visuals."
:sections="sections"
class="py-0"
>
<template #icon="{ feature }">
<component
:is="featureIcon(feature)"
v-if="featureIcon(feature)"
class="size-5"
aria-hidden="true"
/>
</template>

<template #media="{ section }">
<div class="rounded-2xl border bg-muted/40 p-2 shadow-sm">
<div class="relative flex aspect-video items-center justify-center overflow-hidden rounded-xl border border-dashed bg-background/80">
<div class="absolute inset-0 bg-[radial-gradient(circle_at_1px_1px,hsl(var(--muted-foreground)/0.18)_1px,transparent_0)] [background-size:18px_18px]" />
<div class="relative max-w-xs rounded-xl border bg-card p-5 text-center shadow-sm">
<p class="text-sm font-medium text-muted-foreground">
{{ section.id === "compose" ? "Media slot" : "Reversed layout" }}
</p>
<p class="mt-2 text-lg font-semibold tracking-tight">
{{ section.title }}
</p>
</div>
</div>
</div>
</template>
</FeatureSplit>
</template>
169 changes: 169 additions & 0 deletions app/registry/blocks/feature-split/FeatureSplit.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { computed } from "vue";
import { cn } from "@/lib/utils";

export interface FeatureSplitFeature {
/** 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;
}

export interface FeatureSplitSection {
/** Stable id for rendering and optional section anchor. */
id?: string;
/** Section title supplied by the consuming app. */
title: string;
/** Supporting section copy supplied by the consuming app. */
description?: string;
/** Reverse text and media columns on large screens. */
reverse?: boolean;
/** Feature details supplied by the consuming app. */
features?: FeatureSplitFeature[] | null;
}

export interface FeatureSplitProps {
/** Optional group title shown above all sections. */
title?: string;
/** Optional group description shown above all sections. */
description?: string;
/** Split sections supplied by the consuming app. */
sections?: FeatureSplitSection[] | null;
/** Additional CSS classes for the section group. */
class?: HTMLAttributes["class"];
}

const props = withDefaults(defineProps<FeatureSplitProps>(), {
sections: () => [],
});

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

function sectionKey(section: FeatureSplitSection, index: number) {
return section.id ?? `${section.title}-${index}`;
}

function featureItems(section: FeatureSplitSection) {
return section.features ?? [];
}

function featureKey(feature: FeatureSplitFeature, index: number) {
return feature.id ?? `${feature.title}-${index}`;
}
</script>

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

<div
v-if="sectionItems.length"
data-slot="feature-split-sections"
:class="cn('space-y-16 md:space-y-24', hasHeader && 'mt-12 md:mt-16')"
>
<article
v-for="(section, sectionIndex) in sectionItems"
:id="section.id"
:key="sectionKey(section, sectionIndex)"
data-slot="feature-split-section"
class="grid items-center gap-10 lg:grid-cols-2 lg:gap-16"
>
<div :class="section.reverse ? 'lg:order-2' : undefined">
<h3
data-slot="feature-split-section-title"
class="text-balance text-2xl font-semibold tracking-tight sm:text-3xl"
>
{{ section.title }}
</h3>
<p
v-if="section.description"
data-slot="feature-split-section-description"
class="mt-4 max-w-2xl text-pretty text-base/7 text-muted-foreground"
>
{{ section.description }}
</p>

<dl
v-if="featureItems(section).length"
data-slot="feature-split-features"
class="mt-8 grid gap-5"
>
<div
v-for="(feature, featureIndex) in featureItems(section)"
:key="featureKey(feature, featureIndex)"
data-slot="feature-split-feature"
class="flex gap-4 rounded-xl p-2 -m-2"
>
<div
v-if="$slots.icon"
data-slot="feature-split-feature-icon"
class="flex size-10 shrink-0 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary"
>
<slot
name="icon"
:feature="feature"
:feature-index="featureIndex"
:section="section"
:section-index="sectionIndex"
/>
</div>
<div>
<dt class="font-medium">
{{ feature.title }}
</dt>
<dd
v-if="feature.description"
class="mt-1 text-sm/6 text-muted-foreground"
>
{{ feature.description }}
</dd>
</div>
</div>
</dl>
</div>

<div
v-if="$slots.media"
data-slot="feature-split-media"
:class="section.reverse ? 'lg:order-1' : undefined"
>
<slot
name="media"
:section="section"
:section-index="sectionIndex"
/>
</div>
</article>
</div>
</div>
</section>
</template>
1 change: 1 addition & 0 deletions app/registry/blocks/feature-split/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as FeatureSplit, type FeatureSplitFeature, type FeatureSplitProps, type FeatureSplitSection } from "./FeatureSplit.vue";
114 changes: 114 additions & 0 deletions content/docs/2.components/17.feature-split.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
---
title: FeatureSplit
description: Render alternating split feature sections with app-owned media and icon slots.
category: marketing
---

::component-preview
---
name: FeatureSplitDemo
---
::

## Installation

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

## Usage

```vue
<script setup lang="ts">
import type { Component } from 'vue'
import { Boxes, PanelsTopLeft } from 'lucide-vue-next'
import { FeatureSplit, type FeatureSplitFeature, type FeatureSplitSection } from '@/components/feature-split'

const sections: FeatureSplitSection[] = [
{
id: 'compose',
title: 'Compose product stories',
description: 'Render split layouts while your app owns media and icons.',
features: [
{ id: 'slots', title: 'Slot-owned media', description: 'Bring screenshots, cards, videos, or custom SVGs.' },
{ id: 'features', title: 'Display-ready features', description: 'Map CMS records or static copy into a portable API.' }
]
}
]

const icons: Partial<Record<string, Component>> = {
slots: PanelsTopLeft,
features: Boxes
}

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

<template>
<FeatureSplit :sections="sections">
<template #icon="{ feature }">
<component v-if="featureIcon(feature)" :is="featureIcon(feature)" class="size-5" aria-hidden="true" />
</template>

<template #media="{ section }">
<div class="rounded-2xl border bg-muted p-8">
{{ section.title }} media
</div>
</template>
</FeatureSplit>
</template>
```

## App-Owned Media and Icons

`FeatureSplit` owns the responsive split-section layout. Your app owns screenshots, videos, custom art, icon packages, routing, analytics, localization, and content sources.

Pass media through the scoped `media` slot and feature icons through the scoped `icon` slot. The component intentionally does not accept Nuxt Icon string names or import local placeholder components.

## Examples

### Default

::component-preview
---
name: FeatureSplitDemo
---
::

## API Reference

### Props

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `title` | `string` | — | Optional group title shown above all sections. |
| `description` | `string` | — | Optional group description shown above all sections. |
| `sections` | `FeatureSplitSection[] \| null` | `[]` | Split sections supplied by your app. |
| `class` | `string` | — | Additional CSS classes for the section group. |

### Types

```ts
interface FeatureSplitFeature {
id?: string
title: string
description?: string
}

interface FeatureSplitSection {
id?: string
title: string
description?: string
reverse?: boolean
features?: FeatureSplitFeature[] | null
}
```

### Slots

| Slot | Props | Description |
|------|-------|-------------|
| `icon` | `{ feature, featureIndex, section, sectionIndex }` | Optional icon markup rendered beside each feature. |
| `media` | `{ section, sectionIndex }` | Optional media panel rendered beside section copy. |
4 changes: 4 additions & 0 deletions scripts/registry-verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,10 @@ const expectedItems: Record<string, ExpectedRegistryItem> = {
dependencies: ["@lucide/vue"],
registryDependencies: [],
},
"feature-split": {
dependencies: [],
registryDependencies: [],
},
};

if (registryIndex) {
Expand Down
Loading