An infinite logo marquee with two rows of wordmarks that travel in opposite directions and sharpen as they cross the middle of the page and dissolve toward both edges. The scrolling is pure CSS, so it also runs in the plain HTML version. Hovering a row eases it down to a crawl instead of freezing it, and the hovered wordmark darkens while its glyph picks up the accent colour. On phones the wordmarks shrink one step and the heading stacks above the rows.
import { useEffect, useRef, useState } from "react";
import type { RefObject } from "react";
type GlyphKind = "half" | "peak" | "rings" | "tiles" | "arc" | "diamond" | "bars" | "leaf" | "wave" | "pills" | "orbit" | "notch";
type Mark = { name: string; glyph: GlyphKind; type: string };
// Edit the two rows here. `type` is the Tailwind classes that give each wordmark its own voice.
const rows: { seconds: number; reverse: boolean; marks: Mark[] }[] = [
{
seconds: 46,
reverse: false,
marks: [
{ name: "Norvane", glyph: "half", type: "font-serif italic tracking-tight" },
{ name: "KILNWORKS", glyph: "tiles", type: "font-sans font-black tracking-[0.18em] text-[0.82em]" },
{ name: "lumaoak", glyph: "leaf", type: "font-sans font-semibold tracking-tight" },
{ name: "Halvern", glyph: "arc", type: "font-serif font-medium" },
{ name: "FERROWATT", glyph: "bars", type: "font-mono font-bold tracking-tight text-[0.86em]" },
{ name: "Tidewell", glyph: "wave", type: "font-sans font-light tracking-wide" },
],
},
{
seconds: 58,
reverse: true,
marks: [
{ name: "Marrowfield", glyph: "peak", type: "font-serif font-semibold tracking-tight" },
{ name: "quantry", glyph: "rings", type: "font-sans font-extrabold tracking-tighter" },
{ name: "OBRIST & CO", glyph: "diamond", type: "font-serif tracking-[0.22em] text-[0.74em]" },
{ name: "Parcelle", glyph: "pills", type: "font-sans font-medium italic" },
{ name: "verdigo", glyph: "orbit", type: "font-mono font-medium tracking-tight text-[0.9em]" },
{ name: "Altavane", glyph: "notch", type: "font-sans font-bold tracking-tight" },
],
},
];
const css = `
@keyframes dual-row-logo-marquee-with-edge-fade-slide { to { transform: translate3d(-50%, 0, 0); } }
@keyframes dual-row-logo-marquee-with-edge-fade-rise { from { opacity: 0; transform: translate3d(0, 18px, 0); } to { opacity: 1; transform: none; } }
.dual-row-logo-marquee-with-edge-fade-track { animation: dual-row-logo-marquee-with-edge-fade-slide linear infinite; will-change: transform; }
.dual-row-logo-marquee-with-edge-fade-rise { animation: dual-row-logo-marquee-with-edge-fade-rise 0.8s cubic-bezier(0.2, 0.7, 0.2, 1) both; }
.dual-row-logo-marquee-with-edge-fade-mask {
-webkit-mask-image: linear-gradient(to right, transparent, rgba(0,0,0,0.28) 16%, #000 40%, #000 60%, rgba(0,0,0,0.28) 84%, transparent);
mask-image: linear-gradient(to right, transparent, rgba(0,0,0,0.28) 16%, #000 40%, #000 60%, rgba(0,0,0,0.28) 84%, transparent);
}
@media (prefers-reduced-motion: reduce) {
.dual-row-logo-marquee-with-edge-fade-track, .dual-row-logo-marquee-with-edge-fade-rise { animation: none; }
}
`;
function Glyph({ kind }: { kind: GlyphKind }) {
const p = { viewBox: "0 0 24 24", "aria-hidden": true, className: "h-[0.95em] w-[0.95em] flex-none text-stone-900 transition-colors duration-300 group-hover:text-orange-700" } as const;
const line = { fill: "none", stroke: "currentColor", strokeWidth: 2.2, strokeLinecap: "round" } as const;
switch (kind) {
case "half": return <svg {...p}><circle cx="12" cy="12" r="9" {...line} /><path d="M12 3a9 9 0 0 1 0 18Z" fill="currentColor" /></svg>;
case "peak": return <svg {...p}><path d="M2 20 9.5 5l4.2 8 2.3-3.6L22 20Z" fill="currentColor" /></svg>;
case "rings": return <svg {...p}><circle cx="9" cy="12" r="6" {...line} /><circle cx="15" cy="12" r="6" {...line} /></svg>;
case "tiles": return <svg {...p}><rect x="3" y="3" width="8" height="8" rx="1.5" fill="currentColor" /><rect x="13" y="13" width="8" height="8" rx="1.5" fill="currentColor" /><rect x="14" y="4" width="6" height="6" rx="1" {...line} /><rect x="4" y="14" width="6" height="6" rx="1" {...line} /></svg>;
case "arc": return <svg {...p}><path d="M3 18a9 9 0 0 1 18 0" {...line} /><path d="M8 18a4 4 0 0 1 8 0Z" fill="currentColor" /></svg>;
case "diamond": return <svg {...p}><path d="M12 2.5 21.5 12 12 21.5 2.5 12Z" {...line} strokeLinejoin="round" /><path d="M12 8 16 12 12 16 8 12Z" fill="currentColor" /></svg>;
case "bars": return <svg {...p}><rect x="3" y="11" width="4" height="10" rx="1" fill="currentColor" /><rect x="10" y="3" width="4" height="18" rx="1" fill="currentColor" /><rect x="17" y="7" width="4" height="14" rx="1" fill="currentColor" /></svg>;
case "leaf": return <svg {...p}><path d="M4 20C4 10 10 4 20 4c0 10-6 16-16 16Z" fill="currentColor" /></svg>;
case "wave": return <svg {...p}><path d="M2 9c3.3-4 6.7 4 10 0s6.7-4 10 0M2 16c3.3-4 6.7 4 10 0s6.7-4 10 0" {...line} /></svg>;
case "pills": return <svg {...p}><rect x="2" y="4" width="14" height="7" rx="3.5" fill="currentColor" /><rect x="8" y="13" width="14" height="7" rx="3.5" {...line} /></svg>;
case "orbit": return <svg {...p}><ellipse cx="12" cy="12" rx="10" ry="4.5" transform="rotate(-28 12 12)" {...line} /><circle cx="12" cy="12" r="3.5" fill="currentColor" /></svg>;
default: return <svg {...p}><path d="M3 3h18v18h-7v-6a2 2 0 0 0-4 0v6H3Z" fill="currentColor" /></svg>;
}
}
/** Eases the CSS animation's speed toward `rate`, so hover slows the row instead of freezing it. */
function useEasedRate(ref: RefObject<HTMLElement | null>, rate: number) {
useEffect(() => {
const el = ref.current;
if (!el || typeof el.getAnimations !== "function") return;
let frame = 0;
const step = () => {
let settled = true;
for (const a of el.getAnimations()) {
const next = a.playbackRate + (rate - a.playbackRate) * 0.1;
if (Math.abs(rate - next) > 0.01) { a.playbackRate = next; settled = false; } else a.playbackRate = rate;
}
if (!settled) frame = requestAnimationFrame(step);
};
frame = requestAnimationFrame(step);
return () => cancelAnimationFrame(frame);
}, [ref, rate]);
}
function Row({ marks, seconds, reverse, running, delay }: { marks: Mark[]; seconds: number; reverse: boolean; running: boolean; delay: string }) {
const track = useRef<HTMLDivElement>(null);
const [hover, setHover] = useState(false);
const [focus, setFocus] = useState(false);
useEasedRate(track, focus ? 0 : hover ? 0.18 : 1);
return (
<div
className="dual-row-logo-marquee-with-edge-fade-rise dual-row-logo-marquee-with-edge-fade-mask overflow-hidden py-7 sm:py-9"
style={{ animationDelay: delay }}
onPointerEnter={(e) => e.pointerType === "mouse" && setHover(true)}
onPointerLeave={() => setHover(false)}
onFocus={() => setFocus(true)}
onBlur={() => setFocus(false)}
>
<div
ref={track}
className="dual-row-logo-marquee-with-edge-fade-track flex w-max"
style={{ animationDuration: `${seconds}s`, animationDirection: reverse ? "reverse" : "normal", animationPlayState: running ? "running" : "paused" }}
>
{[0, 1].map((copy) => (
<ul key={copy} aria-hidden={copy === 1} className="flex flex-none items-center">
{marks.map((m) => (
<li key={m.name} className="flex items-center">
<a
href="#"
tabIndex={copy === 1 ? -1 : 0}
className="group flex h-12 items-center gap-3 rounded-md px-7 text-3xl text-stone-900 transition-colors duration-300 hover:text-orange-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-orange-600 sm:px-12 sm:text-[2.6rem]"
>
<Glyph kind={m.glyph} />
<span className={`whitespace-nowrap leading-none ${m.type}`}>{m.name}</span>
</a>
<span aria-hidden="true" className="h-1.5 w-1.5 flex-none rotate-45 bg-orange-600/70" />
</li>
))}
</ul>
))}
</div>
</div>
);
}
export default function DualRowLogoMarqueeWithEdgeFade() {
const section = useRef<HTMLElement>(null);
const [onScreen, setOnScreen] = useState(true);
// Stop the rows when the section is scrolled away.
useEffect(() => {
const el = section.current;
if (!el || typeof IntersectionObserver === "undefined") return;
const io = new IntersectionObserver(([entry]) => setOnScreen(entry.isIntersecting));
io.observe(el);
return () => io.disconnect();
}, []);
return (
<section ref={section} className="overflow-hidden bg-[#f4efe4] py-16 text-stone-900 sm:py-20">
<style>{css}</style>
<div className="dual-row-logo-marquee-with-edge-fade-rise mx-auto flex max-w-6xl flex-col gap-6 px-6 sm:flex-row sm:items-end sm:justify-between">
<div className="max-w-xl">
<p className="flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.2em] text-orange-700">
<span aria-hidden="true" className="h-px w-8 bg-orange-700" />
In good company
</p>
<h2 className="mt-4 font-serif text-4xl leading-[1.05] tracking-tight sm:text-5xl">
The studios shipping on Fieldnote <em className="text-orange-700">this week.</em>
</h2>
</div>
<a href="#" className="inline-flex h-11 flex-none items-center gap-2 self-start rounded-full border border-stone-900 px-5 text-sm font-semibold transition-colors hover:bg-stone-900 hover:text-[#f4efe4] focus:outline-none focus-visible:ring-2 focus-visible:ring-orange-600 focus-visible:ring-offset-2 focus-visible:ring-offset-[#f4efe4] sm:self-auto">
Read their stories
<svg viewBox="0 0 20 20" aria-hidden="true" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M4 10h12M11 5l5 5-5 5" /></svg>
</a>
</div>
<div className="mt-12 divide-y divide-stone-900/15 border-y border-stone-900/15 sm:mt-16">
{rows.map((r, i) => (
<Row key={i} marks={r.marks} seconds={r.seconds} reverse={r.reverse} running={onScreen} delay={`${0.15 + i * 0.12}s`} />
))}
</div>
<p className="dual-row-logo-marquee-with-edge-fade-rise mx-auto mt-8 max-w-6xl px-6 text-sm text-stone-600" style={{ animationDelay: "0.45s" }}>
2,400 studios in 31 countries. Logos sharpen as they cross the middle; hover a row to slow it down.
</p>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/dual-row-logo-marquee-with-edge-fade.json
The track holds two identical lists side by side and a keyframe moves it left by exactly 50 percent of its own width. At that point the second list sits where the first one started, so the animation restarts with no visible jump. Spacing is padding inside each item, not a flex gap, which keeps the two halves the same width.
The row has a CSS `mask-image` with a horizontal gradient that goes from transparent to solid and back. A mask fades the logos themselves, so it works on any background, including images and gradients, unlike a coloured overlay.
The movement is a CSS animation. A small hook reads it with `getAnimations()` and eases its `playbackRate` toward a target each frame. If the browser lacks that API the row simply keeps its normal speed.
Yes. Replace the `Glyph` and the text span with an `img` or an inline SVG of a fixed height, for example `h-8`, and keep the padding on the link so the spacing stays even.
No. An IntersectionObserver sets `animation-play-state` to paused when the section leaves the viewport and resumes it on return.
Related: Logo Cloud with Drawn Wordmarks, Three Column Testimonial Wall with Vertical Scroll, Live News Ticker Bar with Pulsing Dot.
More marquees or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.