# Draggable Marquee (Directional)

> An infinite marquee that responds to drag direction and momentum — lively for logos, tags, and announcements.

**Definition:** Draggable Marquee (Directional) is a sliders UI animation from collections.html. It is built with HTML, CSS, JavaScript, and GSAP, ships with a live preview, and is free to copy into personal and commercial projects.

## Facts
- Category: Sliders
- Stack: HTML, CSS, JavaScript, and GSAP
- Added: 2026-08-01
- Canonical page: https://html.bosconvertpro.com/collection/draggable-marquee-directional
- Full-page preview: https://html.bosconvertpro.com/collection/draggable-marquee-directional/full
- Live demo: https://html.bosconvertpro.com/demos/draggable-marquee-directional/index.html
- Markdown mirror: https://html.bosconvertpro.com/collection/draggable-marquee-directional/index.md
- License: https://html.bosconvertpro.com/terms

## How to use
1. Open https://html.bosconvertpro.com/collection/draggable-marquee-directional/full to preview the animation in full screen.
2. Copy index.html, styles.css, and script.js from the code tabs on https://html.bosconvertpro.com/collection/draggable-marquee-directional.
3. Paste the files into your project and adjust timing, easing, and content as needed.

## FAQ
### What is Draggable Marquee (Directional)?

Draggable Marquee (Directional) is a sliders UI animation from collections.html. An infinite marquee that responds to drag direction and momentum — lively for logos, tags, and announcements.

### What is Draggable Marquee (Directional) built with?

Draggable Marquee (Directional) is built with HTML, CSS, JavaScript, and GSAP. The source for every file is shown on the animation page and can be copied straight into your project.

### How do I use Draggable Marquee (Directional) in my project?

Open the animation page (https://html.bosconvertpro.com/collection/draggable-marquee-directional), preview it live, then copy index.html, styles.css, and script.js from the code tabs. Paste the files into your project and adjust timing, easing, and content as needed. A standalone demo runs at https://html.bosconvertpro.com/demos/draggable-marquee-directional/index.html.

### Is Draggable Marquee (Directional) free to use?

Yes. The Draggable Marquee (Directional) demo code may be used in personal and commercial projects. You may not resell or redistribute the collection itself as a competing template pack. Full terms: https://html.bosconvertpro.com/terms.

## Source — index.html
````html
<!DOCTYPE html>
<html lang="en" data-theme="dark">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta
      name="description"
      content="Draggable Marquee (Directional) — infinite feedback cards with GSAP"
    />
    <title>Draggable Marquee (Directional)</title>

    <link rel="preconnect" href="https://fonts.googleapis.com" />
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
    <link rel="preconnect" href="https://images.unsplash.com" />
    <link rel="preconnect" href="https://cdn.jsdelivr.net" />

    <link
      href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&family=Instrument+Serif:ital@0;1&display=swap"
      rel="stylesheet"
    />
    <link rel="stylesheet" href="styles.css" />
  </head>
  <body>
    <div class="page">
      <div class="page__glow" aria-hidden="true"></div>
      <div class="page__grain" aria-hidden="true"></div>

      <header class="site-header">
        <div class="site-header__brand">
          <span class="site-header__mark" aria-hidden="true"></span>
          <span class="site-header__label">Client Stories</span>
        </div>

        <button
          type="button"
          class="theme-toggle"
          data-theme-toggle
          aria-label="Switch to light mode"
          title="Toggle theme"
        >
          <span class="theme-toggle__icon theme-toggle__icon--sun" aria-hidden="true">
            <svg width="18" height="18" viewBox="0 0 24 24" fill="none">
              <circle cx="12" cy="12" r="4" stroke="currentColor" stroke-width="1.75" />
              <path
                d="M12 2v2.5M12 19.5V22M4.93 4.93l1.77 1.77M17.3 17.3l1.77 1.77M2 12h2.5M19.5 12H22M4.93 19.07l1.77-1.77M17.3 6.7l1.77-1.77"
                stroke="currentColor"
                stroke-width="1.75"
                stroke-linecap="round"
              />
            </svg>
          </span>
          <span class="theme-toggle__icon theme-toggle__icon--moon" aria-hidden="true">
            <svg width="18" height="18" viewBox="0 0 24 24" fill="none">
              <path
                d="M21 14.3A8.5 8.5 0 0 1 9.7 3 7 7 0 1 0 21 14.3Z"
                stroke="currentColor"
                stroke-width="1.75"
                stroke-linejoin="round"
              />
            </svg>
          </span>
        </button>
      </header>

      <main class="hero">
        <div class="hero__copy">
          <p class="hero__eyebrow">Trusted on Upwork</p>
          <h1 class="hero__title">
            What clients say<br />
            <em>after the project ships</em>
          </h1>
          <p class="hero__hint" data-drag-hint>
            <span class="hero__hint-icon" aria-hidden="true">
              <svg width="16" height="16" viewBox="0 0 24 24" fill="none">
                <path
                  d="M8 12H4m0 0 3-3M4 12l3 3M16 12h4m0 0-3-3m3 3-3 3"
                  stroke="currentColor"
                  stroke-width="1.75"
                  stroke-linecap="round"
                  stroke-linejoin="round"
                />
              </svg>
            </span>
            Drag either way — marquee keeps your direction
          </p>
        </div>

        <section
          class="marquee"
          data-marquee
          aria-label="Client feedback marquee"
        >
          <div class="marquee__fade marquee__fade--left" aria-hidden="true"></div>
          <div class="marquee__fade marquee__fade--right" aria-hidden="true"></div>

          <div class="marquee__viewport" data-marquee-viewport>
            <div class="marquee__track" data-marquee-track>
              <!-- Cards injected by JS for clean duplication -->
            </div>
          </div>
        </section>
      </main>

      <footer class="site-footer">
        <p class="site-footer__text">
          Drag · release · direction sticks
        </p>
      </footer>
    </div>

    <!-- Feedback card template -->
    <template id="feedback-card-template">
      <article class="feedback-card" data-feedback-card>
        <div class="feedback-card__top">
          <div class="feedback-card__stars" data-stars aria-label="5 star rating">
            <svg viewBox="0 0 12 12" width="12" height="12" aria-hidden="true">
              <path
                d="M6 0.8l1.4 3.1 3.4.3-2.6 2.3.8 3.3L6 8.2 3 9.8l.8-3.3L1.2 4.2l3.4-.3L6 .8z"
                fill="currentColor"
              />
            </svg>
          </div>
          <span class="feedback-card__badge" title="Verified on Upwork">
            <svg
              class="feedback-card__upwork"
              viewBox="0 0 32 32"
              width="18"
              height="18"
              aria-hidden="true"
            >
              <rect width="32" height="32" fill="#14A800" />
              <path
                fill="#fff"
                d="M22.05 14.9c-.79 0-1.53-.33-2.2-.88l.16-.77.01-.03c.15-.82.61-2.19 2.03-2.19 1.07 0 1.94.87 1.94 1.93 0 1.07-.87 1.94-1.94 1.94zm0-5.83c-1.82 0-3.23 1.18-3.8 3.13-.86-1.29-1.51-2.83-1.88-4.12h-2.1v5.09c0 .96-.78 1.75-1.75 1.75s-1.75-.79-1.75-1.75V8.08H8.3v5.09c0 2.09 1.7 3.8 3.79 3.8 2.08 0 3.78-1.71 3.78-3.8v-.85c.38.79.85 1.6 1.41 2.31l-1.2 5.64h2.09l.87-4.09c.76.49 1.64.8 2.36.8 2.15 0 3.9-1.76 3.9-3.9 0-2.15-1.75-3.9-3.9-3.9z"
              />
            </svg>
            Upwork
          </span>
        </div>

        <blockquote class="feedback-card__quote" data-quote></blockquote>

        <div class="feedback-card__author">
          <img
            class="feedback-card__avatar"
            data-avatar
            alt=""
            width="44"
            height="44"
            loading="lazy"
            decoding="async"
          />
          <div class="feedback-card__meta">
            <strong class="feedback-card__name" data-name></strong>
            <span class="feedback-card__role" data-role></span>
          </div>
        </div>
      </article>
    </template>

    <script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
    <script src="script.js"></script>
  </body>
</html>
````

## Source — styles.css
````css
/* ─────────────────────────────────────────────
   Draggable Marquee (Directional)
   Feedback cards · GSAP · Dark / Light
   ───────────────────────────────────────────── */

:root,
[data-theme="dark"] {
  --bg: #000000;
  --bg-elevated: #0a0a0a;
  --bg-card: #0a0a0a;
  --bg-card-hover: #111111;
  --ink: #ffffff;
  --ink-soft: rgba(255, 255, 255, 0.86);
  --ink-faint: rgba(255, 255, 255, 0.68);
  --line: rgba(255, 255, 255, 0.14);
  --line-strong: rgba(255, 255, 255, 0.24);
  --glow: transparent;
  --glow-warm: transparent;
  --star: #f5c518;
  --upwork: #14a800;
  --fade: var(--bg);
  --shadow-card: none;
  --radius-card: 0;
  --radius-control: 0;
  --font-sans: "DM Sans", system-ui, -apple-system, sans-serif;
  --font-serif: "Instrument Serif", "Times New Roman", Georgia, serif;
  --ease-out: cubic-bezier(0.22, 1, 0.36, 1);
  --marquee-gap: 1rem;
  --card-width: clamp(20rem, 72vw, 28.5rem);
  color-scheme: dark;
}

[data-theme="light"] {
  --bg: #ffffff;
  --bg-elevated: #f5f5f5;
  --bg-card: #ffffff;
  --bg-card-hover: #fafafa;
  --ink: #000000;
  --ink-soft: rgba(0, 0, 0, 0.78);
  --ink-faint: rgba(0, 0, 0, 0.6);
  --line: rgba(0, 0, 0, 0.12);
  --line-strong: rgba(0, 0, 0, 0.2);
  --glow: transparent;
  --glow-warm: transparent;
  --star: #f5c518;
  --upwork: #14a800;
  --fade: var(--bg);
  --shadow-card: none;
  color-scheme: light;
}

*,
*::before,
*::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

html,
body {
  height: 100%;
}

body {
  font-family: var(--font-sans);
  background: var(--bg);
  color: var(--ink);
  font-size: 16px;
  line-height: 1.5;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-rendering: optimizeLegibility;
  overflow-x: hidden;
}

button {
  font: inherit;
  color: inherit;
  background: none;
  border: none;
  cursor: pointer;
}

img {
  display: block;
  max-width: 100%;
}

/* ── Page shell ─────────────────────────────── */

.page {
  position: relative;
  min-height: 100%;
  min-height: 100dvh;
  display: flex;
  flex-direction: column;
  overflow: hidden;
  background: var(--bg);
  transition: background-color 0.35s ease, color 0.35s ease;
}

.page__glow {
  display: none;
}

.page__grain {
  display: none;
}

/* ── Header ─────────────────────────────────── */

.site-header {
  position: relative;
  z-index: 5;
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 1.25rem clamp(1.25rem, 4vw, 2.5rem);
}

.site-header__brand {
  display: flex;
  align-items: center;
  gap: 0.65rem;
}

.site-header__mark {
  width: 0.55rem;
  height: 0.55rem;
  border-radius: 50%;
  background: var(--upwork);
  box-shadow: 0 0 0 4px color-mix(in srgb, var(--upwork) 22%, transparent);
}

.site-header__label {
  font-size: 0.875rem;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--ink-soft);
}

.theme-toggle {
  position: relative;
  width: 2.5rem;
  height: 2.5rem;
  display: grid;
  place-items: center;
  border-radius: var(--radius-control);
  border: 1px solid var(--line-strong);
  background: var(--bg);
  color: var(--ink);
  transition:
    background 0.25s ease,
    border-color 0.25s ease;
}

.theme-toggle:hover {
  border-color: var(--ink);
  background: var(--bg-elevated);
}

.theme-toggle:focus-visible {
  outline: 2px solid var(--upwork);
  outline-offset: 3px;
}

.theme-toggle__icon {
  grid-area: 1 / 1;
  display: grid;
  place-items: center;
  transition:
    opacity 0.3s ease,
    transform 0.35s var(--ease-out);
}

[data-theme="dark"] .theme-toggle__icon--moon,
[data-theme="light"] .theme-toggle__icon--sun {
  opacity: 0;
  transform: scale(0.6) rotate(-20deg);
  pointer-events: none;
}

[data-theme="dark"] .theme-toggle__icon--sun,
[data-theme="light"] .theme-toggle__icon--moon {
  opacity: 1;
  transform: scale(1) rotate(0deg);
}

/* ── Hero ───────────────────────────────────── */

.hero {
  position: relative;
  z-index: 2;
  flex: 1;
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: clamp(2rem, 5vw, 3.25rem);
  padding-block: clamp(1rem, 3vh, 2rem) clamp(2rem, 5vh, 3.5rem);
}

.hero__copy {
  padding-inline: clamp(1.25rem, 4vw, 2.5rem);
  max-width: 42rem;
}

.hero__eyebrow {
  display: inline-flex;
  align-items: center;
  gap: 0.45rem;
  margin-bottom: 0.85rem;
  font-size: 0.875rem;
  font-weight: 650;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--upwork);
}

.hero__eyebrow::before {
  content: "";
  width: 1.25rem;
  height: 1px;
  background: currentColor;
  opacity: 0.85;
}

.hero__title {
  font-family: var(--font-serif);
  font-size: clamp(2.15rem, 5.5vw, 3.65rem);
  font-weight: 400;
  line-height: 1.12;
  letter-spacing: -0.02em;
  color: var(--ink);
}

.hero__title em {
  font-style: italic;
  color: var(--ink-soft);
}

.hero__hint {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  margin-top: 1.15rem;
  font-size: 0.9375rem;
  font-weight: 500;
  line-height: 1.45;
  color: var(--ink-soft);
  transition: opacity 0.4s ease;
}

.hero__hint.is-hidden {
  opacity: 0.55;
}

.hero__hint-icon {
  display: grid;
  place-items: center;
  color: var(--ink);
}

/* ── Marquee ────────────────────────────────── */

.marquee {
  position: relative;
  width: 100%;
  user-select: none;
  -webkit-user-select: none;
  touch-action: pan-y;
}

.marquee.is-dragging {
  cursor: grabbing;
}

.marquee:not(.is-dragging) {
  cursor: grab;
}

.marquee__viewport {
  overflow: hidden;
  width: 100%;
  padding-block: 0.35rem 0.75rem;
}

.marquee__track {
  display: flex;
  width: max-content;
  gap: var(--marquee-gap);
  will-change: transform;
  padding-inline: clamp(1rem, 3vw, 1.5rem);
}

.marquee__fade {
  position: absolute;
  top: 0;
  bottom: 0;
  width: clamp(2.5rem, 8vw, 6rem);
  z-index: 3;
  pointer-events: none;
}

.marquee__fade--left {
  left: 0;
  background: linear-gradient(90deg, var(--fade) 0%, transparent 100%);
}

.marquee__fade--right {
  right: 0;
  background: linear-gradient(270deg, var(--fade) 0%, transparent 100%);
}

/* ── Feedback card ──────────────────────────── */

.feedback-card {
  flex: 0 0 auto;
  width: var(--card-width);
  display: flex;
  flex-direction: column;
  gap: 1.15rem;
  padding: clamp(1.2rem, 2.2vw, 1.55rem) clamp(1.25rem, 2.4vw, 1.7rem);
  border-radius: var(--radius-card);
  border: 1px solid var(--line-strong);
  background: var(--bg-card);
  box-shadow: var(--shadow-card);
  transition:
    background 0.25s ease,
    border-color 0.25s ease;
}

.feedback-card:hover {
  background: var(--bg-card-hover);
  border-color: var(--ink);
}

.feedback-card__top {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.75rem;
}

.feedback-card__stars {
  display: flex;
  gap: 0.22rem;
  color: var(--star);
}

.feedback-card__stars svg {
  width: 13px;
  height: 13px;
}

.feedback-card__badge {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  padding: 0.32rem 0.6rem 0.32rem 0.32rem;
  border-radius: 0;
  border: 1px solid var(--line-strong);
  background: transparent;
  font-size: 0.8125rem;
  font-weight: 650;
  letter-spacing: 0.02em;
  text-transform: uppercase;
  color: var(--ink);
}

.feedback-card__upwork {
  display: block;
  border-radius: 0;
  flex-shrink: 0;
  width: 18px;
  height: 18px;
}

.feedback-card__quote {
  flex: 1;
  font-size: clamp(1.05rem, 1.7vw, 1.2rem);
  line-height: 1.6;
  color: var(--ink);
  font-weight: 500;
  letter-spacing: -0.005em;
}

.feedback-card__quote::before {
  content: "“";
  color: var(--ink-soft);
  margin-right: 0.05em;
}

.feedback-card__quote::after {
  content: "”";
  color: var(--ink-soft);
  margin-left: 0.05em;
}

.feedback-card__author {
  display: flex;
  align-items: center;
  gap: 0.85rem;
  border-top: 1px solid var(--line);
  margin-top: auto;
  padding-top: 1.05rem;
}

.feedback-card__avatar {
  width: 2.85rem;
  height: 2.85rem;
  border-radius: 0;
  object-fit: cover;
  border: 1px solid var(--line-strong);
  background: var(--bg-elevated);
  flex-shrink: 0;
}

.feedback-card__meta {
  display: flex;
  flex-direction: column;
  gap: 0.2rem;
  min-width: 0;
}

.feedback-card__name {
  font-size: 1rem;
  font-weight: 700;
  letter-spacing: -0.01em;
  line-height: 1.3;
  color: var(--ink);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.feedback-card__role {
  font-size: 0.875rem;
  font-weight: 500;
  line-height: 1.35;
  color: var(--ink-soft);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* ── Footer ─────────────────────────────────── */

.site-footer {
  position: relative;
  z-index: 2;
  padding: 0 clamp(1.25rem, 4vw, 2.5rem) 1.35rem;
}

.site-footer__text {
  font-size: 0.8125rem;
  font-weight: 500;
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: var(--ink-soft);
}

/* ── Reduced motion ─────────────────────────── */

@media (prefers-reduced-motion: reduce) {
  .feedback-card,
  .theme-toggle,
  .hero__hint {
    transition: none;
  }
}

@media (max-width: 640px) {
  :root,
  [data-theme="dark"],
  [data-theme="light"] {
    --card-width: min(22.5rem, 88vw);
  }

  .feedback-card {
    padding: 1.15rem 1.2rem;
  }

  .hero__title {
    font-size: clamp(1.85rem, 8vw, 2.4rem);
  }
}

@media (min-width: 1100px) {
  :root,
  [data-theme="dark"],
  [data-theme="light"] {
    --card-width: clamp(26rem, 30vw, 30rem);
  }
}
````

## Source — script.js
````javascript
/**
 * Draggable Marquee (Directional)
 * Infinite feedback cards · drag flips direction · GSAP
 */

(function () {
  "use strict";

  // ── Feedback data (Unsplash portraits that resolve reliably) ──
  const FEEDBACKS = [
    {
      name: "Sarah Chen",
      role: "Founder · Bloom Studio",
      quote:
        "Delivered a polished product ahead of schedule. Communication was clear every step — exactly what you want on Upwork.",
      rating: 5,
      avatar:
        "https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=120&h=120&q=80",
    },
    {
      name: "Marcus Reid",
      role: "CTO · Northline Apps",
      quote:
        "Animations feel premium without hurting performance. Our landing page conversion jumped within the first week.",
      rating: 5,
      avatar:
        "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?auto=format&fit=crop&w=120&h=120&q=80",
    },
    {
      name: "Elena Vargas",
      role: "Product Lead · Kite",
      quote:
        "Thoughtful motion design and clean code. Hand-off was documentation-ready — our team shipped the same day.",
      rating: 5,
      avatar:
        "https://images.unsplash.com/photo-1438761681033-6461ffad8d80?auto=format&fit=crop&w=120&h=120&q=80",
    },
    {
      name: "James Okonkwo",
      role: "CEO · Atlas Finance",
      quote:
        "Rare mix of aesthetics and engineering. The marquee and micro-interactions made our brand feel world-class.",
      rating: 5,
      avatar:
        "https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?auto=format&fit=crop&w=120&h=120&q=80",
    },
    {
      name: "Priya Nair",
      role: "Marketing Dir. · Lumen",
      quote:
        "Client feedback cards look fantastic in both themes. Drag feels buttery — our stakeholders were impressed live.",
      rating: 5,
      avatar:
        "https://images.unsplash.com/photo-1544005313-94ddf0286df2?auto=format&fit=crop&w=120&h=120&q=80",
    },
    {
      name: "Daniel Foster",
      role: "Design Manager · Orbit",
      quote:
        "Pixel-perfect implementation of the brief. Easy to customize, and the Upwork collaboration was seamless.",
      rating: 5,
      avatar:
        "https://images.unsplash.com/photo-1500648767791-00dcc994a43e?auto=format&fit=crop&w=120&h=120&q=80",
    },
    {
      name: "Amina Hassan",
      role: "Founder · Softcraft",
      quote:
        "Professional from kickoff to delivery. We reused the pattern across three campaigns without a hitch.",
      rating: 5,
      avatar:
        "https://images.unsplash.com/photo-1534528741775-53994a69daeb?auto=format&fit=crop&w=120&h=120&q=80",
    },
    {
      name: "Noah Bergman",
      role: "Head of Growth · Peak",
      quote:
        "Smooth directional drag is the detail that sells the craft. Best freelancer experience we've had this year.",
      rating: 5,
      avatar:
        "https://images.unsplash.com/photo-1519345182560-3f2917c472ef?auto=format&fit=crop&w=120&h=120&q=80",
    },
  ];

  const BASE_SPEED = 0.65; // px per frame-ish (scaled by dt)
  const DRAG_INFLUENCE = 1;
  const VELOCITY_DECAY = 0.94;
  const MIN_VELOCITY = 0.08;
  const DIRECTION_THRESHOLD = 0.35;

  // ── Theme ───────────────────────────────────────────────────
  function initTheme() {
    const root = document.documentElement;
    const toggle = document.querySelector("[data-theme-toggle]");
    if (!toggle) return;

    const stored = localStorage.getItem("marquee-theme");
    const prefersLight =
      window.matchMedia &&
      window.matchMedia("(prefers-color-scheme: light)").matches;
    const initial = stored || (prefersLight ? "light" : "dark");
    applyTheme(initial);

    toggle.addEventListener("click", () => {
      const next = root.getAttribute("data-theme") === "dark" ? "light" : "dark";
      applyTheme(next);
      localStorage.setItem("marquee-theme", next);
    });

    function applyTheme(theme) {
      root.setAttribute("data-theme", theme);
      toggle.setAttribute(
        "aria-label",
        theme === "dark" ? "Switch to light mode" : "Switch to dark mode"
      );
    }
  }

  // ── Build cards ─────────────────────────────────────────────
  function buildCards(track, template) {
    const fragment = document.createDocumentFragment();

    FEEDBACKS.forEach((item) => {
      const node = template.content.cloneNode(true);
      const card = node.querySelector("[data-feedback-card]");
      const stars = node.querySelector("[data-stars]");
      const quote = node.querySelector("[data-quote]");
      const avatar = node.querySelector("[data-avatar]");
      const name = node.querySelector("[data-name]");
      const role = node.querySelector("[data-role]");

      quote.textContent = item.quote;
      name.textContent = item.name;
      role.textContent = item.role;
      avatar.src = item.avatar;
      avatar.alt = item.name;
      stars.setAttribute("aria-label", `${item.rating} star rating`);

      // Fill star icons
      const starSvg = stars.querySelector("svg");
      if (starSvg) {
        const starTemplate = starSvg.cloneNode(true);
        stars.innerHTML = "";
        for (let i = 0; i < item.rating; i++) {
          stars.appendChild(starTemplate.cloneNode(true));
        }
      }

      card.setAttribute("data-name", item.name);
      fragment.appendChild(node);
    });

    track.appendChild(fragment);
  }

  function duplicateForLoop(track) {
    const originals = Array.from(track.children);
    if (!originals.length) return;

    // Enough copies so the loop never shows a gap on wide screens
    const copies = 2;
    for (let c = 0; c < copies; c++) {
      originals.forEach((el) => {
        const clone = el.cloneNode(true);
        clone.setAttribute("aria-hidden", "true");
        // Avoid duplicate image decoding thrash on clones if already loaded
        const img = clone.querySelector("img");
        if (img) img.loading = "lazy";
        track.appendChild(clone);
      });
    }
  }

  // ── Marquee engine ──────────────────────────────────────────
  function initMarquee(root) {
    const viewport = root.querySelector("[data-marquee-viewport]");
    const track = root.querySelector("[data-marquee-track]");
    const template = document.getElementById("feedback-card-template");
    const hint = document.querySelector("[data-drag-hint]");

    if (!viewport || !track || !template) return;

    buildCards(track, template);
    duplicateForLoop(track);

    let x = 0;
    let direction = -1; // -1 left (default marquee), +1 right
    let velocity = 0;
    let isDragging = false;
    let dragStartX = 0;
    let dragStartOffset = 0;
    let lastPointerX = 0;
    let lastTime = performance.now();
    let pointerId = null;
    let loopWidth = 0;
    let hasDragged = false;
    let reducedMotion = window.matchMedia(
      "(prefers-reduced-motion: reduce)"
    ).matches;

    function measure() {
      const firstSetCount = FEEDBACKS.length;
      const cards = track.children;
      if (cards.length < firstSetCount) {
        loopWidth = track.scrollWidth / 3;
        return;
      }

      // Width of one original set (gap included via offset of first clone)
      const first = cards[0];
      const boundary = cards[firstSetCount];
      if (boundary) {
        loopWidth = boundary.offsetLeft - first.offsetLeft;
      } else {
        loopWidth = track.scrollWidth / 3;
      }
    }

    function wrap(value) {
      if (!loopWidth) return value;
      // Keep x in (-loopWidth, 0]
      let v = value % loopWidth;
      if (v > 0) v -= loopWidth;
      if (v <= -loopWidth) v += loopWidth;
      return v;
    }

    function render() {
      gsap.set(track, { x: x, force3D: true });
    }

    function onPointerDown(e) {
      if (e.button !== undefined && e.button !== 0) return;

      isDragging = true;
      pointerId = e.pointerId;
      dragStartX = e.clientX;
      dragStartOffset = x;
      lastPointerX = e.clientX;
      lastTime = performance.now();
      velocity = 0;

      root.classList.add("is-dragging");
      if (hint) hint.classList.add("is-hidden");

      try {
        viewport.setPointerCapture(e.pointerId);
      } catch (_) {
        /* ignore */
      }
    }

    function onPointerMove(e) {
      if (!isDragging || (pointerId !== null && e.pointerId !== pointerId)) {
        return;
      }

      const now = performance.now();
      const dt = Math.max(now - lastTime, 1);
      const dx = e.clientX - dragStartX;

      x = wrap(dragStartOffset + dx * DRAG_INFLUENCE);
      render();

      const frameDx = e.clientX - lastPointerX;
      velocity = frameDx / dt; // px per ms
      lastPointerX = e.clientX;
      lastTime = now;

      if (Math.abs(dx) > 4) hasDragged = true;
    }

    function onPointerUp(e) {
      if (!isDragging) return;
      if (pointerId !== null && e.pointerId !== pointerId) return;

      isDragging = false;
      pointerId = null;
      root.classList.remove("is-dragging");

      // Directional behavior: keep moving in the drag direction
      const speed = velocity * 16; // rough px/frame scale
      if (Math.abs(speed) > DIRECTION_THRESHOLD) {
        direction = speed > 0 ? 1 : -1;
      }

      try {
        viewport.releasePointerCapture(e.pointerId);
      } catch (_) {
        /* ignore */
      }
    }

    // Wheel: light nudge + optional direction flip
    function onWheel(e) {
      if (Math.abs(e.deltaX) < Math.abs(e.deltaY) && Math.abs(e.deltaX) < 2) {
        return;
      }
      e.preventDefault();
      const delta = e.deltaX !== 0 ? e.deltaX : e.deltaY;
      x = wrap(x - delta * 0.45);
      if (Math.abs(delta) > 2) {
        direction = delta > 0 ? -1 : 1;
      }
      velocity = 0;
      render();
      if (hint) hint.classList.add("is-hidden");
    }

    function tick(time) {
      const dt = Math.min(time - lastTick, 48);
      lastTick = time;

      if (!isDragging && !reducedMotion) {
        // Blend drag inertia into constant cruise in the chosen direction
        const cruise = direction * BASE_SPEED * (dt / 16);
        if (Math.abs(velocity) > MIN_VELOCITY / 16) {
          x = wrap(x + velocity * dt);
          velocity *= Math.pow(VELOCITY_DECAY, dt / 16);
          // Ease into cruise when inertia is low
          if (Math.abs(velocity) * dt < Math.abs(cruise) * 1.15) {
            velocity = 0;
            x = wrap(x + cruise);
          }
        } else {
          x = wrap(x + cruise);
        }
        render();
      }

      requestAnimationFrame(tick);
    }

    let lastTick = performance.now();

    // Events
    viewport.addEventListener("pointerdown", onPointerDown);
    viewport.addEventListener("pointermove", onPointerMove);
    viewport.addEventListener("pointerup", onPointerUp);
    viewport.addEventListener("pointercancel", onPointerUp);
    viewport.addEventListener("wheel", onWheel, { passive: false });

    // Prevent image drag ghosts
    track.addEventListener("dragstart", (e) => e.preventDefault());

    // Resize
    let resizeTimer;
    const ro =
      typeof ResizeObserver !== "undefined"
        ? new ResizeObserver(() => {
            clearTimeout(resizeTimer);
            resizeTimer = setTimeout(() => {
              measure();
              x = wrap(x);
              render();
            }, 80);
          })
        : null;

    if (ro) ro.observe(track);
    window.addEventListener("resize", () => {
      measure();
      x = wrap(x);
      render();
    });

    window
      .matchMedia("(prefers-reduced-motion: reduce)")
      .addEventListener("change", (e) => {
        reducedMotion = e.matches;
      });

    // Wait for images so measurement is accurate
    const images = Array.from(track.querySelectorAll("img"));
    let pending = images.length;

    function start() {
      measure();
      x = 0;
      render();
      requestAnimationFrame(tick);

      // Soft entrance on cards (track x is driven separately)
      gsap.fromTo(
        track.querySelectorAll("[data-feedback-card]"),
        { y: 18, opacity: 0 },
        {
          y: 0,
          opacity: 1,
          duration: 0.7,
          stagger: 0.04,
          ease: "power3.out",
        }
      );
    }

    if (pending === 0) {
      start();
    } else {
      images.forEach((img) => {
        if (img.complete) {
          pending -= 1;
          if (pending === 0) start();
        } else {
          img.addEventListener(
            "load",
            () => {
              pending -= 1;
              if (pending === 0) start();
            },
            { once: true }
          );
          img.addEventListener(
            "error",
            () => {
              // Fallback avatar if Unsplash fails
              img.src =
                "data:image/svg+xml," +
                encodeURIComponent(
                  `<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120"><rect fill="#2a2e38" width="120" height="120"/><circle cx="60" cy="48" r="22" fill="#5a6270"/><ellipse cx="60" cy="100" rx="34" ry="24" fill="#5a6270"/></svg>`
                );
              pending -= 1;
              if (pending === 0) start();
            },
            { once: true }
          );
        }
      });
      // Safety timeout
      setTimeout(() => {
        if (pending > 0) {
          pending = 0;
          start();
        }
      }, 2500);
    }

    // Expose for debugging
    return {
      setDirection(dir) {
        direction = dir >= 0 ? 1 : -1;
      },
      get hasDragged() {
        return hasDragged;
      },
    };
  }

  // ── Boot ────────────────────────────────────────────────────
  function boot() {
    initTheme();
    const root = document.querySelector("[data-marquee]");
    if (root) initMarquee(root);
  }

  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", boot);
  } else {
    boot();
  }
})();
````
