Retour au catalogue
Marquee Gradient Fade
Deux rows de texte défilant en sens opposés avec fade gradient large sur les bords. Texte alterné normal/serif-italic. Fond avec glow radial central. Ralentissement progressif au hover.
marqueemedium Both Responsive a11y
elegantminimaleditorialsaasagencyuniversalfullscreen
Theme
"use client";
import { useState } from "react";
import { motion, useAnimation } from "framer-motion";
interface MarqueeGradientFadeProps {
rowOne?: string[];
rowTwo?: string[];
speedRow1?: number;
speedRow2?: number;
label?: string;
}
interface MarqueeItem {
text: string;
serif: boolean;
}
function buildItems(items: string[]): MarqueeItem[] {
return items.map((text, i) => ({ text, serif: i % 3 === 1 }));
}
function MarqueeTrack({
items,
duration,
reverse,
slowDuration,
isSlowing,
}: {
items: MarqueeItem[];
duration: number;
reverse: boolean;
slowDuration: number;
isSlowing: boolean;
}) {
const controls = useAnimation();
const doubled = [...items, ...items];
const from = reverse ? "-50%" : "0%";
const to = reverse ? "0%" : "-50%";
const activeDuration = isSlowing ? slowDuration : duration;
return (
<div style={{ overflow: "hidden", position: "relative" }}>
<motion.div
animate={controls}
key={`track-${isSlowing}-${activeDuration}`}
initial={{ x: from }}
style={{
display: "flex",
whiteSpace: "nowrap",
willChange: "transform",
}}
onViewportEnter={() => {
controls.start({
x: [from, to],
transition: {
duration: activeDuration,
ease: "linear",
repeat: Infinity,
repeatType: "loop",
},
});
}}
>
{doubled.map((item, i) => (
<span
key={`${item.text}-${i}`}
style={{
display: "inline-flex",
alignItems: "center",
flexShrink: 0,
paddingRight: "3rem",
gap: "3rem",
fontSize: "clamp(1.125rem, 2.25vw, 1.625rem)",
letterSpacing: "-0.015em",
lineHeight: 1,
color: item.serif
? "var(--color-foreground-muted)"
: "var(--color-foreground)",
fontWeight: item.serif ? 400 : 600,
fontStyle: item.serif ? "italic" : "normal",
fontFamily: item.serif ? "var(--font-serif)" : "inherit",
}}
>
{item.text}
<span
aria-hidden
style={{
width: 5,
height: 5,
borderRadius: "var(--radius-full)",
background: "var(--color-accent)",
opacity: 0.6,
flexShrink: 0,
display: "inline-block",
}}
/>
</span>
))}
</motion.div>
</div>
);
}
export default function MarqueeGradientFade({
rowOne = [],
rowTwo = [],
speedRow1 = 28,
speedRow2 = 22,
label,
}: MarqueeGradientFadeProps) {
const [isSlowing, setIsSlowing] = useState(false);
const itemsOne = buildItems(rowOne);
const itemsTwo = buildItems(rowTwo);
const slowMultiplier = 3.5;
return (
<section
aria-label={label ?? "Marquee"}
onMouseEnter={() => setIsSlowing(true)}
onMouseLeave={() => setIsSlowing(false)}
style={{
position: "relative",
overflow: "hidden",
padding: "4rem 0",
background: "var(--color-background)",
cursor: "default",
}}
>
{/* Radial glow — centre */}
<div
aria-hidden
style={{
position: "absolute",
inset: 0,
background:
"radial-gradient(ellipse 60% 70% at 50% 50%, color-mix(in srgb, var(--color-accent) 8%, transparent), transparent 70%)",
pointerEvents: "none",
zIndex: 0,
}}
/>
{/* Left fade */}
<div
aria-hidden
style={{
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: "22%",
background:
"linear-gradient(to right, var(--color-background) 10%, transparent 100%)",
zIndex: 2,
pointerEvents: "none",
}}
/>
{/* Right fade */}
<div
aria-hidden
style={{
position: "absolute",
right: 0,
top: 0,
bottom: 0,
width: "22%",
background:
"linear-gradient(to left, var(--color-background) 10%, transparent 100%)",
zIndex: 2,
pointerEvents: "none",
}}
/>
{/* Rows */}
<div style={{ position: "relative", zIndex: 1, display: "flex", flexDirection: "column", gap: "1.25rem" }}>
{itemsOne.length > 0 && (
<MarqueeTrack
items={itemsOne}
duration={speedRow1}
reverse={false}
slowDuration={speedRow1 * slowMultiplier}
isSlowing={isSlowing}
/>
)}
{itemsTwo.length > 0 && (
<MarqueeTrack
items={itemsTwo}
duration={speedRow2}
reverse={true}
slowDuration={speedRow2 * slowMultiplier}
isSlowing={isSlowing}
/>
)}
</div>
</section>
);
}