Files
sass-mohfarawati/components/layout/stacked-marquee-section.tsx
T

230 lines
5.7 KiB
TypeScript

"use client";
import { useLayoutEffect, useRef, useState } from "react";
import { motion, useAnimationFrame, useMotionValue, useReducedMotion } from "framer-motion";
import { cn } from "@/lib/utils";
type MarqueeDirection = "left" | "right";
export type MarqueeRow = {
items: string[];
direction: MarqueeDirection;
duration?: number;
};
type StackedMarqueeSectionProps = {
className?: string;
rows?: MarqueeRow[];
};
const DEFAULT_ROWS: MarqueeRow[] = [
{
direction: "left",
duration: 126,
items: [
"Next.js",
"React",
"WordPress",
"Docker",
"Technical SEO",
"Tailwind CSS",
"Google Analytics",
"Framer Motion",
"Cloudflare",
"Performance",
],
},
{
direction: "right",
duration: 220,
items: [
"TypeScript",
"Node.js",
"WooCommerce",
"Schema Markup",
"DevOps",
"Responsive Design",
"Core Web Vitals",
"Plausible Analytics",
"Design Systems",
"Server Infrastructure",
],
},
{
direction: "left",
duration: 168,
items: [
"JavaScript",
"REST APIs",
"Git",
"Linux",
"Search Console",
"Conversion Tracking",
"UI Engineering",
"Accessibility",
"Component Architecture",
"Web Performance",
],
},
{
direction: "right",
duration: 144,
items: [
"Frontend Strategy",
"CMS Integration",
"Motion Design",
"Content Architecture",
"E-Commerce",
"SEO Audits",
"Deployments",
"Scalable UI",
"Analytics Setup",
"Product Pages",
],
},
];
function MarqueeRowTrack({
items,
direction,
duration = 24,
}: MarqueeRow) {
const shouldReduceMotion = useReducedMotion();
const viewportRef = useRef<HTMLDivElement>(null);
const segmentRef = useRef<HTMLDivElement>(null);
const [segmentWidth, setSegmentWidth] = useState(0);
const [copyCount, setCopyCount] = useState(3);
const x = useMotionValue(0);
const isReady = shouldReduceMotion || segmentWidth > 0;
useLayoutEffect(() => {
const viewport = viewportRef.current;
const segment = segmentRef.current;
if (!viewport || !segment) {
return;
}
const measure = () => {
const nextSegmentWidth = segment.getBoundingClientRect().width;
const viewportWidth = viewport.clientWidth;
if (!nextSegmentWidth || !viewportWidth) {
return;
}
setSegmentWidth(nextSegmentWidth);
setCopyCount(Math.max(3, Math.ceil(viewportWidth / nextSegmentWidth) + 2));
x.set(direction === "right" ? -nextSegmentWidth : 0);
};
measure();
const observer = new ResizeObserver(() => {
measure();
});
observer.observe(viewport);
observer.observe(segment);
return () => {
observer.disconnect();
};
}, [direction, items, x]);
useAnimationFrame((_, delta) => {
if (shouldReduceMotion || segmentWidth === 0) {
return;
}
const distancePerMs = segmentWidth / (duration * 1000);
const offset = distancePerMs * delta;
const current = x.get();
if (direction === "right") {
const next = current + offset;
x.set(next >= 0 ? next - segmentWidth : next);
return;
}
const next = current - offset;
x.set(next <= -segmentWidth ? next + segmentWidth : next);
});
return (
<div
ref={viewportRef}
dir="ltr"
className="w-full overflow-hidden font-latin leading-[0.92]"
>
<motion.div
dir="ltr"
className={cn("flex w-max flex-nowrap", !isReady && "opacity-0")}
style={{ x }}
>
{Array.from({ length: copyCount }).map((_, copyIndex) => (
<div
key={`${direction}-${copyIndex}`}
ref={copyIndex === 0 ? segmentRef : undefined}
className="flex shrink-0 items-center gap-3 pr-3 sm:gap-4 sm:pr-4 lg:gap-5 lg:pr-5"
aria-hidden={copyIndex > 0}
dir="ltr"
>
{items.map((item, itemIndex) => (
<div
key={`${copyIndex}-${item}`}
className="flex shrink-0 items-center gap-3 sm:gap-4 lg:gap-5"
dir="ltr"
>
<span
className={cn(
"shrink-0 whitespace-nowrap text-[clamp(2.05rem,6vw,4.8rem)] uppercase leading-[0.92] tracking-[-0.01em] text-[hsl(var(--surface-3))] dark:text-[hsl(var(--surface-3))] [text-shadow:0_1px_2px_hsl(222_24%_10%_/_0.06)]",
itemIndex % 2 === 0 ? "font-black" : "font-light",
)}
>
{item}
</span>
<span
className="text-[clamp(1.85rem,5vw,4rem)] font-light leading-[0.92] tracking-[-0.01em] text-[hsl(var(--surface-3))] dark:text-[hsl(var(--surface-3))] [text-shadow:0_1px_2px_hsl(222_24%_10%_/_0.05)]"
aria-hidden="true"
>
/
</span>
</div>
))}
</div>
))}
</motion.div>
</div>
);
}
export function StackedMarqueeSection({
className,
rows = DEFAULT_ROWS,
}: StackedMarqueeSectionProps) {
return (
<section
aria-label="Technology marquee"
dir="ltr"
className={cn(
"relative isolate overflow-hidden bg-transparent py-3 sm:py-4 lg:py-5",
className,
)}
>
<div className="relative flex flex-col gap-0 sm:gap-0 lg:gap-0">
{rows.map((row, index) => (
<div key={`${row.direction}-${index}`} dir="ltr" className="w-full">
<MarqueeRowTrack
items={row.items}
direction={row.direction}
duration={row.duration}
/>
</div>
))}
</div>
</section>
);
}