Loading component…
Components /Magnetic Spotlight Marquee
Interactive full-screen marquee with spotlight cursor effect
"use client";
import React, { useRef, useState, useEffect, ReactNode } from "react";
import gsap from "gsap";
import { cn } from "@/lib/utils";
interface MagneticSpotlightMarqueeProps {
className?: string;
images?: string[];
title?: string[];
subtitle?: string[];
paragraphs?: string[][];
navEmail?: string;
navLinks?: string;
footerText?: string;
}
const config = {
marqueeScrollSpeed: 180, // Increased for a faster, dynamic feel
stripFollowEase: 0.05,
stripEdgeInset: 175,
contentRiseRate: 0.85,
risenTopGap: 100,
liftHeadStart: 125,
wakeStrength: 2.5,
wakeReach: 125,
lineSettleEase: 0.09,
};
const DEFAULT_IMAGES = [
"https://images.unsplash.com/photo-1578632767115-351597cf2477?q=80&w=800&auto=format&fit=crop",
"https://images.unsplash.com/photo-1607604276583-eef5d076aa5f?q=80&w=800&auto=format&fit=crop",
"https://images.unsplash.com/photo-1541562232579-512a21360020?q=80&w=800&auto=format&fit=crop",
"https://images.unsplash.com/photo-1581833971358-2c8b550f87b3?q=80&w=800&auto=format&fit=crop",
"https://images.unsplash.com/photo-1560972550-aba3456b5564?q=80&w=800&auto=format&fit=crop",
"https://images.unsplash.com/photo-1613376023733-0a73315d9b06?q=80&w=800&auto=format&fit=crop",
];
const DEFAULT_TITLE = ["VengeanceUI"];
const DEFAULT_SUBTITLE = ["BUILD FASTER", "SHIP BETTER"];
const DEFAULT_PARAGRAPHS = [
[
"Vengeance UI is a premium component library",
"specializing in smooth animations, interactive",
"interfaces, and modern design.",
],
[
"We prioritize developer experience and aesthetics.",
"Our components span across complex interactions,",
"3D elements, and smooth animations built",
"for React and modern frameworks. Our library is tailored",
"to distinct challenges within modern web development."
]
];
export function MagneticSpotlightMarquee({
className,
images = DEFAULT_IMAGES,
title = DEFAULT_TITLE,
subtitle = DEFAULT_SUBTITLE,
paragraphs = DEFAULT_PARAGRAPHS,
navEmail = "hello@vengeance.ui",
navLinks = "Documentation, Components, GitHub",
footerText = "We navigate in no-nonsense environments pushing the boundaries of web design. Whether you're a startup or a global leader, building a new identity or interactive platform, Vengeance UI is your partner in innovation. Our premium components ensure that every project feels magical, collaborative, and smooth.",
}: MagneticSpotlightMarqueeProps) {
const containerRef = useRef<HTMLDivElement>(null);
const marqueeStripRef = useRef<HTMLDivElement>(null);
const marqueeTrackRef = useRef<HTMLDivElement>(null);
const contentWrapperRef = useRef<HTMLDivElement>(null);
// State to hold cloned images to fill width
const [clonedImages, setClonedImages] = useState<string[]>(images);
useEffect(() => {
if (!marqueeTrackRef.current || !marqueeStripRef.current || !containerRef.current || !contentWrapperRef.current) return;
const marqueeTrack = marqueeTrackRef.current;
// 1. Setup infinite horizontal marquee with GSAP
// Calculate width statically to avoid issues with unloaded images
const isMobile = window.innerWidth < 768;
const itemWidth = isMobile ? 140 : 180; // Smaller, square width
const gap = 16; // 1rem gap
const oneSetWidth = images.length * (itemWidth + gap);
const setsNeeded = Math.ceil(window.innerWidth / oneSetWidth) + 2;
const newImages = [];
for (let i = 0; i < setsNeeded; i++) {
newImages.push(...images);
}
setClonedImages(newImages);
// Wait for React to render clones, then animate
const ctx = gsap.context(() => {
setTimeout(() => {
gsap.to(marqueeTrack, {
x: `-${oneSetWidth}px`,
duration: oneSetWidth / 600, // Hardcoded even faster speed (600)
ease: "none",
repeat: -1,
modifiers: {
x: (x) => `${gsap.utils.wrap(-oneSetWidth, 0, parseFloat(x))}px`
}
});
}, 100);
}, marqueeTrack);
return () => ctx.revert();
}, [images]);
// Wake effect logic
useEffect(() => {
if (!containerRef.current || !marqueeStripRef.current || !contentWrapperRef.current) return;
const spotlightSection = containerRef.current;
const marqueeStrip = marqueeStripRef.current;
let stripBaseTop = 0;
let stripHeight = 0;
let sectionHeight = 0;
let stripRestCenterY = 0;
let contentTopAtRest = 0;
let stripTargetY = 0;
let stripCurrentY = 0;
let stripPrevY = 0;
let hasPointerMoved = false;
let targets: { el: HTMLElement; restCenterY: number; currentY: number }[] = [];
let rafId: number;
const measureGeometry = () => {
sectionHeight = spotlightSection.getBoundingClientRect().height;
stripBaseTop = marqueeStrip.offsetTop;
stripHeight = marqueeStrip.offsetHeight;
stripRestCenterY = config.stripEdgeInset;
const elements = Array.from(spotlightSection.querySelectorAll('.wake-target')) as HTMLElement[];
let blockTop = Infinity;
targets = elements.map(el => {
let y = 0;
let node: HTMLElement | null = el;
while (node && node !== spotlightSection) {
y += node.offsetTop;
node = node.offsetParent as HTMLElement;
}
const restCenterY = y + el.offsetHeight / 2;
blockTop = Math.min(blockTop, restCenterY - el.offsetHeight / 2);
return {
el,
restCenterY,
currentY: 0
};
});
contentTopAtRest = isFinite(blockTop) ? blockTop : sectionHeight * 0.4;
if (!hasPointerMoved) {
const restY = config.stripEdgeInset - stripHeight / 2;
stripTargetY = restY;
stripCurrentY = restY;
stripPrevY = restY;
gsap.set(marqueeStrip, { y: stripCurrentY });
}
};
setTimeout(measureGeometry, 100);
window.addEventListener('resize', measureGeometry);
const handlePointerMove = (e: MouseEvent) => {
hasPointerMoved = true;
const rect = spotlightSection.getBoundingClientRect();
const pointerY = e.clientY - rect.top;
stripTargetY = pointerY - stripHeight / 2;
};
const handlePointerLeave = () => {
hasPointerMoved = false;
stripTargetY = config.stripEdgeInset - stripHeight / 2;
};
spotlightSection.addEventListener('mousemove', handlePointerMove);
spotlightSection.addEventListener('mouseleave', handlePointerLeave);
const render = () => {
stripCurrentY += (stripTargetY - stripCurrentY) * config.stripFollowEase;
gsap.set(marqueeStrip, { y: stripCurrentY });
const stripCenterY = stripBaseTop + stripCurrentY + stripHeight / 2;
const stripVelocityY = stripCurrentY - stripPrevY;
stripPrevY = stripCurrentY;
const descentBelowRest = Math.max(0, stripCenterY - stripRestCenterY);
const maxRise = Math.max(0, contentTopAtRest - config.risenTopGap);
const contentRise = -Math.min(
descentBelowRest * config.contentRiseRate,
maxRise
);
targets.forEach(line => {
const gapToStrip = line.restCenterY - stripCenterY;
const reachedLine = stripCenterY + config.liftHeadStart >= line.restCenterY;
const wakeInfluence = Math.exp(
-(gapToStrip * gapToStrip) / (2 * config.wakeReach * config.wakeReach)
);
const wakeOffset = stripVelocityY * wakeInfluence * config.wakeStrength;
const lineTarget = (reachedLine ? contentRise : 0) + wakeOffset;
line.currentY += (lineTarget - line.currentY) * config.lineSettleEase;
gsap.set(line.el, { y: line.currentY });
});
rafId = requestAnimationFrame(render);
};
rafId = requestAnimationFrame(render);
return () => {
window.removeEventListener('resize', measureGeometry);
spotlightSection.removeEventListener('mousemove', handlePointerMove);
spotlightSection.removeEventListener('mouseleave', handlePointerLeave);
cancelAnimationFrame(rafId);
};
}, []);
return (
<section
ref={containerRef}
className={cn(
"spotlight relative w-full h-[100vh] min-h-[800px] overflow-hidden bg-white text-white font-sans",
className
)}
style={{ fontFamily: "'Instrument Sans', sans-serif" }}
>
{/* Top Nav - Centered layout as seen in screenshot */}
<div className="absolute top-0 left-0 w-full p-6 flex flex-col items-center justify-center z-50 text-[10px] md:text-xs font-medium tracking-wide opacity-90 mix-blend-difference pointer-events-none">
<div>{navEmail}</div>
<div>{navLinks}</div>
</div>
{/* Marquee Strip */}
<div
ref={marqueeStripRef}
className="spotlight-marquee absolute left-0 w-full z-20 h-[160px] md:h-[200px] pointer-events-none"
style={{ top: 0 }}
>
<div
ref={marqueeTrackRef}
className="spotlight-marquee-track flex gap-4 h-full items-center absolute top-0 left-0"
>
{clonedImages.map((img, idx) => (
<div key={idx} className="w-[140px] h-[140px] md:w-[180px] md:h-[180px] shrink-0 rounded-[20px] overflow-hidden shadow-sm bg-neutral-100">
<img
src={img}
alt="Marquee item"
className="w-full h-full object-cover"
loading="lazy"
/>
</div>
))}
</div>
</div>
{/* Main Content Layout */}
<div
ref={contentWrapperRef}
className="spotlight-content-wrapper relative w-full h-full flex flex-col items-center justify-center px-6 md:px-12 lg:px-24 z-30 pointer-events-none mix-blend-difference"
>
{/* Title */}
<h1
className="text-[15vw] md:text-[12rem] font-normal leading-[0.85] tracking-tighter mb-20 text-center flex flex-col items-center"
style={{ fontFamily: "'Instrument Serif', serif" }}
>
{title.map((line, idx) => (
<div key={idx} className="wake-target inline-block relative">
{line}
{/* Optional playful dot for 'Studio' to mimic the screenshot */}
{line === "Studio" && (
<span className="absolute right-[0.45em] top-[0.1em] w-[0.25em] h-[0.25em] bg-white rounded-full"></span>
)}
</div>
))}
</h1>
{/* Subtitle & Paragraphs row */}
<div className="w-full max-w-7xl mx-auto flex flex-col md:flex-row justify-between items-start mt-8 px-4 md:px-8 gap-8 md:gap-4">
{/* Subtitle / Header (Left side) */}
<div className="flex-1 md:max-w-[280px] text-right md:text-right mt-1">
<h3 className="text-xl md:text-3xl uppercase tracking-tight font-medium leading-[1.1]">
{subtitle.map((line, idx) => (
<div key={idx} className="wake-target">{line}</div>
))}
</h3>
</div>
{/* Paragraphs (Right side) */}
<div className="flex-1 flex flex-col sm:flex-row gap-6 md:gap-12 text-[10px] md:text-xs leading-[1.6]">
{paragraphs.map((para, pIdx) => (
<div key={pIdx} className="flex-1 flex flex-col">
{para.map((line, lIdx) => (
<div key={lIdx} className="wake-target whitespace-nowrap">
{line}
</div>
))}
</div>
))}
</div>
</div>
</div>
{/* Footer */}
<div className="absolute bottom-0 left-0 w-full p-8 z-40 flex justify-center pointer-events-none mix-blend-difference">
<p className="text-[8px] md:text-[10px] text-white/70 max-w-2xl text-center leading-[1.6]">
{footerText}
</p>
</div>
</section>
);
}
export default MagneticSpotlightMarquee;
Run the following command
npx shadcn@latest add https://www.vengenceui.com/r/magnetic-spotlight-marquee.json1import { MagneticSpotlightMarquee } from "@/components/ui/magnetic-spotlight-marquee"23export function SpotlightMarqueeDemo() {4 return (5 <MagneticSpotlightMarquee />6 )7}
| Prop Name | Type | Default | Description |
|---|---|---|---|
| images | string[] | DEFAULT_IMAGES | Array of image URLs to use in the infinite marquee track. |
| title | string[] | ['VengeanceUI'] | Array representing lines of the main title. Each element is rendered on its own line. |
| subtitle | string[] | ['BUILD FASTER', 'SHIP BETTER'] | Array representing lines of the subtitle. |
| paragraphs | string[][] | - | Array of paragraph blocks. Each paragraph block is an array of strings representing individual text lines for the 'wake effect'. |
| navEmail | string | 'hello@vengeance.ui' | The email address displayed in the top navigation bar. |
| navLinks | string | 'Documentation, Components, GitHub' | Links displayed in the top navigation bar. |
| footerText | string | - | Text displayed at the bottom of the component. |
| className | string | - | Additional CSS classes. |