# Features 4

> Sticky scroll-stacking feature cards — slide up from below and pile with offset tops.

A shadcn/ui block from the [@nusaiba](https://nusaiba.dev) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/nusaiba/features-4).

- Page, with a live preview: https://shadcn.exchange/r/nusaiba/features-4
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/nusaiba/features-4-light-6cad01e62f6e9d9f.png
- Registry item: https://nusaiba.dev/r/base-nova/features-4.json
- Categories: features
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://nusaiba.dev/r/base-nova/features-4.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `motion`
- Registry items: `@nusaiba/foundation`, `button`, `card`, `@magicui/grid-pattern`

## Files

### `registry/blocks/features/4/features-stack-section.tsx`

```tsx
"use client";

import type { ReactNode } from "react";
import { useRef } from "react";
import {
	CheckIcon,
	FilmIcon,
	ImageIcon,
	LayoutTemplateIcon,
	XIcon,
} from "lucide-react";
import { motion, useReducedMotion } from "motion/react";
import {
	STACK_CARD_TOP_BASE,
	STACK_CARD_TOP_STEP,
	useStackCardScrollMotion,
} from "@/lib/section-scroll";
import { Button, buttonVariants } from "@/components/ui/button";
import {
	Card,
	CardContent,
	CardFooter,
	CardHeader,
	CardTitle,
} from "@/components/ui/card";
import { GridPattern } from "@/components/ui/grid-pattern";
import { cn } from "@/lib/utils";
import { defaultSpring, motionViewport } from "@/lib/motion-tokens";
import { SectionShell, ContentRail } from "@/components/layout-contract";

const STACK_TOP_BASE = STACK_CARD_TOP_BASE;
const STACK_TOP_STEP = STACK_CARD_TOP_STEP;

type FeatureCard = {
	title: string;
	description: string;
	visual: ReactNode;
};

const cards: FeatureCard[] = [
	{
		title: "Pick a template",
		description:
			"Browse looping animations and choose the motion that fits your work — preview each style before you commit.",
		visual: <TemplateVisual />,
	},
	{
		title: "Drop in your media",
		description:
			"Add images or video to every slot, tune aspect ratio and timing, and see updates live in the canvas.",
		visual: <MediaVisual />,
	},
	{
		title: "Export MP4 / WebM",
		description:
			"Render frame-exact from 720p up to 8K, straight from the browser — no desktop app required.",
		visual: <ExportVisual />,
	},
];

function VisualStage({ children }: { children: ReactNode }) {
	return (
		<div className="relative flex h-full min-h-[22rem] items-center justify-center overflow-hidden p-6 md:min-h-[26rem] md:p-10">
			<GridPattern
				aria-hidden
				className="absolute inset-0 mask-[radial-gradient(ellipse_at_center,white,transparent_78%)] stroke-foreground/[0.06]"
				height={48}
				width={48}
			/>
			<div aria-hidden className="absolute inset-0 bg-muted/25" />
			<div className="relative z-10 w-full max-w-md md:max-w-lg">{children}</div>
		</div>
	);
}

function MockOption({
	active,
	children,
	className,
}: {
	active?: boolean;
	children: ReactNode;
	className?: string;
}) {
	return (
		<span
			className={cn(
				buttonVariants({
					variant: active ? "default" : "secondary",
					size: "sm",
				}),
				"pointer-events-none shadow-none",
				className,
			)}
		>
			{children}
		</span>
	);
}

function MockFieldLabel({
	children,
	className,
}: {
	children: ReactNode;
	className?: string;
}) {
	return (
		<p className={cn("block font-medium text-muted-foreground text-sm", className)}>
			{children}
		</p>
	);
}

function MockFieldGroup({
	children,
	inline,
	label,
}: {
	children: ReactNode;
	inline?: boolean;
	label: ReactNode;
}) {
	if (inline) {
		return (
			<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
				<MockFieldLabel className="mb-0">{label}</MockFieldLabel>
				{children}
			</div>
		);
	}

	return (
		<div>
			<MockFieldLabel>{label}</MockFieldLabel>
			<div className="mt-3">{children}</div>
		</div>
	);
}

function TemplateVisual() {
	const presets = [
		{ name: "Showcase Stream", active: true },
		{ name: "Card Tower", active: false },
		{ name: "Film Strip", active: false },
		{ name: "Orbit Carousel", active: false },
	];

	return (
		<VisualStage>
			<Card className="w-full shadow-sm ring-border/80">
				<CardHeader className="border-b">
					<div className="flex items-center gap-2.5">
						<LayoutTemplateIcon aria-hidden className="size-4 text-muted-foreground" />
						<CardTitle className="text-sm uppercase tracking-[0.16em]">
							3D &amp; Perspective
						</CardTitle>
					</div>
				</CardHeader>
				<CardContent className="grid pt-0 md:grid-cols-[9.5rem_1fr]">
					<div className="space-y-2 border-b border-border pb-4 md:border-b-0 md:border-r md:pr-4 md:pb-0">
						{presets.map((preset) => (
							<div
								className={cn(
									"rounded-[var(--radius-md)] border px-3 py-2.5",
									preset.active
										? "border-primary/30 bg-primary/[0.04]"
										: "border-border bg-muted/30",
								)}
								key={preset.name}
							>
								<div className="mb-2 h-8 rounded-[var(--radius-sm)] border border-border/70 bg-muted/50 md:h-9" />
								<p className="truncate font-medium text-sm">{preset.name}</p>
							</div>
						))}
					</div>
					<div className="flex min-h-[11rem] items-center justify-center pt-4 md:min-h-[14rem] md:pt-0 md:pl-4">
						<div className="relative h-32 w-full md:h-40">
							{[0, 1, 2, 3].map((index) => (
								<motion.div
									animate={{ rotate: index * 18 - 27, y: index * -3 }}
									aria-hidden
									className="absolute top-1/2 left-1/2 h-20 w-14 -translate-x-1/2 -translate-y-1/2 rounded-[var(--radius-md)] border border-border bg-card shadow-sm md:h-24 md:w-16"
									key={index}
									style={{ zIndex: index }}
									transition={{
										duration: 4 + index * 0.4,
										repeat: Number.POSITIVE_INFINITY,
										repeatType: "reverse",
										ease: "easeInOut",
									}}
								/>
							))}
						</div>
					</div>
				</CardContent>
			</Card>
		</VisualStage>
	);
}

function MediaVisual() {
	const ratios = ["16:9", "4:3", "1:1", "4:5", "9:16"];
	const slots = ["Slot 1", "Slot 2", "Slot 3"];

	return (
		<VisualStage>
			<Card className="w-full shadow-sm ring-border/80">
				<CardHeader className="flex-row items-center justify-between border-b">
					<CardTitle>Editor</CardTitle>
					<Button className="pointer-events-none" size="sm" type="button">
						Export
					</Button>
				</CardHeader>
				<CardContent className="space-y-6">
					<MockFieldGroup label="Frame">
						<div className="flex flex-wrap gap-2">
							{ratios.map((ratio, index) => (
								<MockOption active={index === 0} key={ratio}>
									{ratio}
								</MockOption>
							))}
						</div>
					</MockFieldGroup>
					<MockFieldGroup label="Media · 12 slots">
						<div className="space-y-2">
							{slots.map((slot, index) => (
								<div
									className="flex min-h-11 items-center gap-3 rounded-[var(--radius-md)] border border-border bg-muted/20 px-4"
									key={slot}
								>
									<ImageIcon aria-hidden className="size-4 text-muted-foreground" />
									<span className="font-medium text-sm">{slot}</span>
									{index === 0 ? (
										<CheckIcon
											aria-hidden
											className="ml-auto size-4 text-emerald-600"
										/>
									) : (
										<span className="ml-auto text-muted-foreground text-sm">empty</span>
									)}
								</div>
							))}
						</div>
					</MockFieldGroup>
				</CardContent>
			</Card>
		</VisualStage>
	);
}

function ExportVisual() {
	const resolutions = ["720p", "1080p", "2K", "4K", "8K"];

	return (
		<VisualStage>
			<Card className="w-full shadow-sm ring-border/80">
				<CardHeader className="flex-row items-center justify-between border-b">
					<div className="flex items-center gap-2.5">
						<FilmIcon aria-hidden className="size-4 text-muted-foreground" />
						<CardTitle>Export video</CardTitle>
					</div>
					<XIcon aria-hidden className="size-4 text-muted-foreground" />
				</CardHeader>
				<CardContent className="space-y-6">
					<MockFieldGroup inline label="Format">
						<div className="flex flex-wrap gap-2">
							<MockOption active>MP4 · H.264</MockOption>
							<MockOption>WebM · VP9</MockOption>
						</div>
					</MockFieldGroup>
					<MockFieldGroup label="Resolution">
						<div className="flex flex-wrap gap-2">
							{resolutions.map((res, index) => (
								<MockOption active={index === 0} key={res}>
									{res}
								</MockOption>
							))}
						</div>
					</MockFieldGroup>
					<div className="rounded-[var(--radius-sm)] border border-border bg-muted/30 px-4 py-3 font-mono text-muted-foreground text-sm">
						720×720 · 18.0s · 8 Mbps · ~17 MB max
					</div>
				</CardContent>
				<CardFooter className="justify-end gap-2 border-t">
					<Button className="pointer-events-none" type="button" variant="ghost">
						Close
					</Button>
					<Button className="pointer-events-none" type="button">
						Export
					</Button>
				</CardFooter>
			</Card>
		</VisualStage>
	);
}

function StackCard({
	card,
	index,
	reduceMotion,
}: {
	card: FeatureCard;
	index: number;
	reduceMotion: boolean;
}) {
	const ref = useRef<HTMLDivElement>(null);
	const { y, scale, filter } = useStackCardScrollMotion(ref, reduceMotion);

	return (
		<div
			ref={ref}
			className="sticky"
			style={{
				top: STACK_TOP_BASE + index * STACK_TOP_STEP,
				zIndex: index + 1,
			}}
		>
			<motion.article
				className={cn(
					"grid overflow-hidden rounded-[var(--radius-lg)] border border-border bg-card",
					"shadow-[0_1px_2px_rgba(0,0,0,0.04),0_10px_28px_rgba(0,0,0,0.07)]",
					"dark:shadow-[0_1px_2px_rgba(0,0,0,0.35),0_10px_28px_rgba(0,0,0,0.45)]",
					"min-h-[min(78vh,40rem)] will-change-transform lg:grid-cols-[minmax(0,0.88fr)_minmax(0,1.12fr)]",
				)}
				style={
					reduceMotion
						? undefined
						: { y, scale, filter, transformOrigin: "center top" }
				}
			>
				<div className="flex flex-col justify-center px-8 py-12 md:px-12 md:py-16 lg:px-14">
					<h3 className="text-balance font-medium text-2xl tracking-tight md:text-4xl">
						{card.title}
					</h3>
					<p className="mt-5 max-w-prose text-pretty text-muted-foreground text-sm leading-relaxed md:text-base">
						{card.description}
					</p>
				</div>
				<div className="relative min-h-[22rem] border-t border-border md:min-h-[26rem] lg:min-h-0 lg:border-t-0 lg:border-l">
					{card.visual}
				</div>
			</motion.article>
		</div>
	);
}

export function FeaturesStackSection() {
	const reduceMotion = useReducedMotion();

	return (
		<SectionShell spacingMode="section">
			<ContentRail maxWidth="max-w-6xl">
				<motion.div
					className="max-w-2xl"
					initial={reduceMotion ? false : { opacity: 0, y: 12, filter: "blur(4px)" }}
					transition={defaultSpring}
					viewport={motionViewport}
					whileInView={reduceMotion ? undefined : { opacity: 1, y: 0, filter: "blur(0px)" }}
				>
					<p className="font-medium text-primary text-xs uppercase tracking-[0.2em]">
						Features
					</p>
					<h2 className="mt-4 text-balance font-medium text-2xl tracking-tight md:text-4xl">
						Scroll to stack each step
					</h2>
					<p className="mt-4 text-pretty text-muted-foreground text-sm md:text-base">
						Sticky cards slide up with scroll and pile at the end — ideal for product
						tours and flagship workflows.
					</p>
				</motion.div>

				<div className="relative mt-12 flex flex-col gap-[14vh] pb-[34vh] md:mt-14">
					{cards.map((card, index) => (
						<StackCard
							card={card}
							index={index}
							key={card.title}
							reduceMotion={!!reduceMotion}
						/>
					))}
				</div>
			</ContentRail>
		</SectionShell>
	);
}
```
