web design
COMPONENT I / KINETIC MARQUEE · SEAMLESS LOOP, PAUSE + REVERSE ON HOVER
TypeScript+ React+ GSAP+ Node+ CSS+ A11y+ Design systems+ Performance+
GUIDE / KINETIC MARQUEE

Overview

A full-bleed band of mono caps scrolling left without a seam. Two identical groups sit side by side, the track shifts by exactly one group width, then loops. Pause on hover eases the timeScale to zero; on leave it resumes the opposite way, so each hover flips the band's direction.

When to use

Exactly once per page: a skills strip under the hero, or a single manifesto line between sections. Two marquees on one page reads as filler. Reduced motion freezes the track at its start position.

Spec

Tokens
--grey items, --grey-dim separators, --line edges
Motion
xPercent -50, 20s linear, pause then reverse
A11y
second group aria-hidden, reduced-motion static
Layout
full-bleed, width max-content, no wrap

Anti-patterns

Never two on a page. No accent separators, the band stays neutral so an accent CTA elsewhere can lead. Do not animate margin or left, transform only.

<!-- Requires GSAP: https://cdnjs.cloudflare.com/ajax/libs/gsap/3.13.0/gsap.min.js -->

<style>
:root{
  --black:#050505;--surface:#0C0C0C;--white:#FFFFFF;--grey:#A3A3A3;--grey-dim:#6B6B6B;--accent:#22D3EE;
  --line:rgba(255,255,255,.16);--line-faint:rgba(255,255,255,.07);
  --font-display:'Space Grotesk',system-ui,sans-serif;--font-mono:'JetBrains Mono',ui-monospace,monospace;
  --dur-fast:150ms;--dur-base:300ms;--ease-out:cubic-bezier(.16,1,.3,1);
}
*{margin:0;padding:0;box-sizing:border-box;border-radius:0!important}
body{background:var(--black);color:var(--white);font-family:var(--font-display);min-height:100vh;padding:64px 48px}
.mq{margin:0 -48px;border-top:1px solid var(--line);border-bottom:1px solid var(--line);overflow:hidden;padding:24px 0}
.mq-track{display:flex;width:max-content;will-change:transform}
.mq-group{display:flex;align-items:center;gap:40px;padding-right:40px}
.mq-item{font-family:var(--font-mono);font-size:clamp(1.125rem,2.4vw,1.75rem);font-weight:500;letter-spacing:.04em;text-transform:uppercase;color:var(--grey);white-space:nowrap;transition:color var(--dur-base) var(--ease-out)}
.mq-group:hover .mq-item{color:var(--white)}
.mq-sep{color:var(--grey-dim);font-weight:400;user-select:none}
</style>

<div class="mq" id="mq">
  <div class="mq-track" id="track">
    <div class="mq-group" aria-hidden="false">
      <span class="mq-item">TypeScript</span><span class="mq-sep">+</span>
      <span class="mq-item">React</span><span class="mq-sep">+</span>
      <span class="mq-item">GSAP</span><span class="mq-sep">+</span>
      <span class="mq-item">Node</span><span class="mq-sep">+</span>
      <span class="mq-item">CSS</span><span class="mq-sep">+</span>
      <span class="mq-item">A11y</span><span class="mq-sep">+</span>
      <span class="mq-item">Design systems</span><span class="mq-sep">+</span>
      <span class="mq-item">Performance</span><span class="mq-sep">+</span>
    </div>
    <div class="mq-group" aria-hidden="true">
      <span class="mq-item">TypeScript</span><span class="mq-sep">+</span>
      <span class="mq-item">React</span><span class="mq-sep">+</span>
      <span class="mq-item">GSAP</span><span class="mq-sep">+</span>
      <span class="mq-item">Node</span><span class="mq-sep">+</span>
      <span class="mq-item">CSS</span><span class="mq-sep">+</span>
      <span class="mq-item">A11y</span><span class="mq-sep">+</span>
      <span class="mq-item">Design systems</span><span class="mq-sep">+</span>
      <span class="mq-item">Performance</span><span class="mq-sep">+</span>
    </div>
  </div>
</div>

<script>
const mm = gsap.matchMedia();
mm.add({
  full:   "(prefers-reduced-motion: no-preference)",
  reduce: "(prefers-reduced-motion: reduce)"
}, (ctx) => {
  if (!ctx.conditions.full) return;
  const tween = gsap.to("#track", {xPercent:-50, duration:20, ease:"none", repeat:-1});
  tween.totalTime(40000); // start deep in the loop so reversing doesn't pin at the seam (~11h headroom)
  const mq = document.getElementById("mq");
  let dir = 1; // flip sign each hover so the band reverses on resume
  mq.addEventListener("mouseenter", () => gsap.to(tween, {timeScale:0, duration:0.4}));
  mq.addEventListener("mouseleave", () => gsap.to(tween, {timeScale:dir = -dir, duration:0.4}));
  return () => tween.kill();
});
</script>