219 lines
5.5 KiB
TypeScript
219 lines
5.5 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.scrollWidth;
|
|
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} className="overflow-hidden 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}
|
|
>
|
|
{items.map((item, itemIndex) => (
|
|
<div
|
|
key={`${copyIndex}-${item}`}
|
|
className="flex shrink-0 items-center gap-3 sm:gap-4 lg:gap-5"
|
|
>
|
|
<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:none]",
|
|
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:none]"
|
|
aria-hidden="true"
|
|
>
|
|
/
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
))}
|
|
</motion.div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function StackedMarqueeSection({
|
|
className,
|
|
rows = DEFAULT_ROWS,
|
|
}: StackedMarqueeSectionProps) {
|
|
return (
|
|
<section
|
|
aria-label="Technology marquee"
|
|
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}`}>
|
|
<MarqueeRowTrack
|
|
items={row.items}
|
|
direction={row.direction}
|
|
duration={row.duration}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|