Sticky scroll-stacking feature cards — slide up from below and pile with offset tops.
@nusaiba
registry/blocks/features/4/features-stack-section.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 & 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>
);
}



























