# Blog 5

> Horizontal scroll snap filmstrip with scale focus on the active cover slide.

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

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

## Install

```bash
npx shadcn@latest add https://nusaiba.dev/r/base-nova/blog-5.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`, `@nusaiba/gallery-visuals`

## Files

### `registry/blocks/blog/5/blog-section.tsx`

```tsx
"use client";

import Image from "next/image";
import Link from "next/link";
import { useCallback, useEffect, useRef, useState } from "react";
import { ArrowRightIcon, ClockIcon } from "lucide-react";
import { motion } from "motion/react";
import { cn } from "@/lib/utils";
import { SectionShell, ContentRail } from "@/components/layout-contract";

const spring = { type: "spring" as const, stiffness: 380, damping: 30 };

const posts = [
	{
		title: "Motion tokens for marketing pages",
		category: "Craft",
		readTime: "7 min",
		image: "/images/visuals/hyperspeed.png",
		alt: "Neon light trails in dark space",
	},
	{
		title: "Designing onboarding that converts",
		category: "Design",
		readTime: "5 min",
		image: "/images/visuals/orb.png",
		alt: "Glowing orbital ring",
	},
	{
		title: "Scroll stacks without jank",
		category: "Motion",
		readTime: "6 min",
		image: "/images/visuals/ripple-grid.png",
		alt: "Distorted white grid on black",
	},
	{
		title: "What SOC 2 taught our team",
		category: "Engineering",
		readTime: "8 min",
		image: "/images/visuals/dark-veil.png",
		alt: "Purple light veil",
	},
	{
		title: "Building a registry developers install",
		category: "Product",
		readTime: "9 min",
		image: "/images/visuals/plasma.png",
		alt: "Plasma smoke on dark field",
	},
];

export function BlogFilmstripSection() {
	const scrollRef = useRef<HTMLDivElement>(null);
	const slideRefs = useRef<(HTMLElement | null)[]>([]);
	const [active, setActive] = useState(0);

	const updateActive = useCallback(() => {
		const container = scrollRef.current;
		if (!container) return;

		const center = container.scrollLeft + container.clientWidth / 2;
		let closest = 0;
		let minDistance = Number.POSITIVE_INFINITY;

		slideRefs.current.forEach((slide, index) => {
			if (!slide) return;
			const slideCenter = slide.offsetLeft + slide.offsetWidth / 2;
			const distance = Math.abs(center - slideCenter);
			if (distance < minDistance) {
				minDistance = distance;
				closest = index;
			}
		});

		setActive(closest);
	}, []);

	useEffect(() => {
		const container = scrollRef.current;
		if (!container) return;

		updateActive();
		container.addEventListener("scroll", updateActive, { passive: true });
		window.addEventListener("resize", updateActive);

		return () => {
			container.removeEventListener("scroll", updateActive);
			window.removeEventListener("resize", updateActive);
		};
	}, [updateActive]);

	return (
		<SectionShell spacingMode="section">
			<ContentRail maxWidth="max-w-6xl" className="space-y-10">
				<div className="max-w-2xl px-1">
					<p className="font-medium text-primary text-xs uppercase tracking-[0.25em]">
						From the journal
					</p>
					<h2 className="mt-3 font-medium text-2xl tracking-tight md:text-4xl">
						Filmstrip snap scroll
					</h2>
					<p className="mt-4 text-muted-foreground text-sm md:text-base">
						Swipe through covers with scroll snap — active slide scales up while
						neighbors recede. Built for editorial browsing on landing pages.
					</p>
				</div>

				<div
					className="flex snap-x snap-mandatory gap-4 overflow-x-auto pb-2 [-ms-overflow-style:none] [scrollbar-width:none] md:gap-6 [&::-webkit-scrollbar]:hidden"
					ref={scrollRef}
				>
					{posts.map((post, index) => {
						const isActive = index === active;

						return (
							<motion.article
								animate={{
									scale: isActive ? 1 : 0.94,
									opacity: isActive ? 1 : 0.55,
								}}
								className="w-[min(88vw,34rem)] shrink-0 snap-center"
								initial={{ opacity: 0, y: 20 }}
								key={post.title}
								ref={(node) => {
									slideRefs.current[index] = node;
								}}
								transition={spring}
								viewport={{ once: true }}
								whileInView={{ opacity: isActive ? 1 : 0.55, y: 0 }}
							>
								<Link className="group block" href="#">
									<div className="relative aspect-[16/10] overflow-hidden rounded-[var(--radius-lg)] border border-border bg-muted/20">
										<Image
											alt={post.alt}
											className="object-cover transition-transform duration-700 group-hover:scale-[1.03]"
											fill
											sizes="(max-width: 768px) 88vw, 34rem"
											src={post.image}
										/>
										<div className="absolute inset-0 bg-linear-to-t from-foreground/50 via-transparent to-transparent opacity-80" />
										<div className="absolute inset-x-0 bottom-0 p-5 md:p-6">
											<p className="font-medium text-white/80 text-xs uppercase tracking-[0.16em]">
												{post.category}
											</p>
											<h3 className="mt-2 text-balance font-medium text-white text-xl tracking-tight md:text-2xl">
												{post.title}
											</h3>
										</div>
									</div>
									<div className="mt-4 flex items-center justify-between gap-3 px-1">
										<span className="inline-flex items-center gap-1 text-muted-foreground text-xs">
											<ClockIcon className="size-3" />
											{post.readTime}
										</span>
										<span className="inline-flex items-center gap-1 font-medium text-sm opacity-0 transition-opacity group-hover:opacity-100">
											Read
											<ArrowRightIcon className="size-3.5" />
										</span>
									</div>
								</Link>
							</motion.article>
						);
					})}
				</div>

				<div className="flex items-center justify-center gap-1.5">
					{posts.map((post, index) => (
						<button
							aria-label={`Go to slide ${index + 1}: ${post.title}`}
							className={cn(
								"h-1.5 rounded-full transition-all duration-300",
								index === active
									? "w-6 bg-foreground"
									: "w-1.5 bg-foreground/25 hover:bg-foreground/40",
							)}
							key={post.title}
							onClick={() => {
								slideRefs.current[index]?.scrollIntoView({
									behavior: "smooth",
									inline: "center",
									block: "nearest",
								});
							}}
							type="button"
						/>
					))}
				</div>
			</ContentRail>
		</SectionShell>
	);
}
```
