registry/blocks/blog/5/blog-section.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>
);
}
















