{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "velocity-marquee",
  "type": "registry:block",
  "title": "Velocity Marquee",
  "description": "An endless marquee that listens to the page: scroll or wheel and it speeds up, leans into the motion and turns with the scroll direction; grab it to scrub and flick it to throw. Hover slows it, and spinning ornaments turn with the distance travelled.",
  "docs": "Velocity Marquee was added to public/vividsites/velocity-marquee.html. Paste its markup, <style> and <script> where you want it on the page (or open the file to see it alone). Customise it with the CSS variables at the top. Docs and live demo: https://vividsites.app/c/velocity-marquee",
  "categories": [
    "ui",
    "text-type"
  ],
  "files": [
    {
      "path": "components/vividsites/velocity-marquee.html",
      "type": "registry:file",
      "target": "public/vividsites/velocity-marquee.html",
      "content": "<!--\n  vs-velocity-marquee: an endless marquee that listens to the page. Scroll and it speeds up, leans\n  into the motion (skew) and turns to follow the scroll direction; stop and it eases back to its\n  cruising speed. Grab it to scrub (mouse or touch) and flick it to throw. Hover slows it down.\n\n  Markup: .vs-velocity-marquee > .vs-velocity-marquee-track > any items. The items are cloned as many\n  times as needed to fill twice the row, so one set is enough. Put several rows on a page; each one\n  reads its own variables.\n    data-vs-velocity-marquee=\"1\"     travels left at rest (default)\n    data-vs-velocity-marquee=\"-1\"    travels right at rest\n  Pages that can't scroll still react to the mouse wheel. Anything with .vs-velocity-marquee-spin\n  inside an item turns with the distance travelled (good for stars, dials, logos).\n\n  Variables:\n    --vs-velocity-marquee-speed      cruising speed, px per second      (70)\n    --vs-velocity-marquee-boost      how much scroll speed adds         (1)\n    --vs-velocity-marquee-skew       maximum lean, degrees              (10)\n    --vs-velocity-marquee-gap        space after each item              (2.5rem)\n    --vs-velocity-marquee-hover      speed factor under the pointer     (.25)\n  Variables are read live (change them inline or by class and the row follows).\n  Paused while off-screen. prefers-reduced-motion: static row, no skew, still draggable.\n-->\n<style>\n  .vs-velocity-marquee {\n    --vs-velocity-marquee-speed: 70;\n    --vs-velocity-marquee-boost: 1;\n    --vs-velocity-marquee-skew: 10;\n    --vs-velocity-marquee-gap: 2.5rem;\n    --vs-velocity-marquee-hover: .25;\n    overflow: hidden; position: relative; white-space: nowrap;\n    cursor: grab; touch-action: pan-y; -webkit-user-select: none; user-select: none;\n  }\n  .vs-velocity-marquee.is-dragging { cursor: grabbing; }\n  .vs-velocity-marquee-track { display: inline-flex; align-items: center; will-change: transform; }\n  .vs-velocity-marquee-track > * { flex: none; margin-right: var(--vs-velocity-marquee-gap); }\n  .vs-velocity-marquee-spin { display: inline-block; will-change: transform; }\n  .vs-velocity-marquee:focus-visible { outline: 2px solid currentColor; outline-offset: 4px; }\n</style>\n<script>\n(() => {\n  if (window.VsVelocityMarquee) return;\n  const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;\n  const cssNum = (el, n, fb) => { const v = parseFloat(getComputedStyle(el).getPropertyValue(n)); return isNaN(v) ? fb : v; };\n  const rows = new Set();\n\n  function mount(row) {\n    const track = row.querySelector('.vs-velocity-marquee-track');\n    if (!track || row.vsVelocityMarquee) return;\n    const originals = [...track.children];\n    const rec = {\n      row, track, originals, x: 0, setW: 1, visible: false, dir: parseFloat(row.dataset.vsVelocityMarquee || '1') < 0 ? -1 : 1,\n      mult: 1, hoverF: 1, hover: false, skew: 0, skewV: 0, drag: null, fling: 0, spins: [],\n    };\n    const cfg = () => (rec.cfg = {\n      speed: cssNum(row, '--vs-velocity-marquee-speed', 70), boost: cssNum(row, '--vs-velocity-marquee-boost', 1),\n      skew: cssNum(row, '--vs-velocity-marquee-skew', 10), hover: cssNum(row, '--vs-velocity-marquee-hover', .25),\n    });\n    cfg();\n    // fill: clone the set until two row-widths are covered; clones are hidden from assistive tech\n    rec.layout = () => {\n      track.querySelectorAll('[data-vs-clone]').forEach((n) => n.remove());\n      const setW = originals.reduce((w, n) => w + n.getBoundingClientRect().width + parseFloat(getComputedStyle(n).marginRight), 0) || 1;\n      rec.setW = setW;\n      const copies = Math.ceil((row.clientWidth * 2) / setW) + 1;\n      for (let i = 0; i < copies; i++) originals.forEach((n) => {\n        const c = n.cloneNode(true); c.setAttribute('aria-hidden', 'true'); c.setAttribute('data-vs-clone', ''); track.appendChild(c);\n      });\n      rec.spins = [...track.querySelectorAll('.vs-velocity-marquee-spin')];\n      cfg();\n    };\n    rec.layout();\n    row.setAttribute('tabindex', row.getAttribute('tabindex') || '0');\n    row.setAttribute('role', row.getAttribute('role') || 'marquee');\n\n    // drag to scrub, flick to throw\n    row.addEventListener('pointerdown', (e) => {\n      rec.drag = { id: e.pointerId, x: e.clientX, t: performance.now(), v: 0, moved: 0 };\n      row.setPointerCapture(e.pointerId); row.classList.add('is-dragging'); kick();\n    });\n    row.addEventListener('pointermove', (e) => {\n      const d = rec.drag; if (!d || d.id !== e.pointerId) return;\n      const now = performance.now(), dx = e.clientX - d.x, dt = Math.max(1, now - d.t);\n      rec.x -= dx; d.v = d.v * .6 + (dx / dt) * 1000 * .4; d.x = e.clientX; d.t = now; d.moved += Math.abs(dx);\n    });\n    const end = (e) => {\n      const d = rec.drag; if (!d || d.id !== e.pointerId) return;\n      rec.fling = -d.v; rec.drag = null; row.classList.remove('is-dragging');\n    };\n    row.addEventListener('pointerup', end); row.addEventListener('pointercancel', end);\n    // swallow the click that ends a drag, so links inside don't fire\n    row.addEventListener('click', (e) => { if (rec.lastMoved > 6) { e.preventDefault(); e.stopPropagation(); } }, true);\n    row.addEventListener('pointerup', () => { rec.lastMoved = rec.drag ? rec.drag.moved : rec.lastMoved; }, true);\n    row.addEventListener('pointerdown', () => { rec.lastMoved = 0; }, true);\n    row.addEventListener('pointerenter', (e) => { if (e.pointerType === 'mouse') rec.hover = true; });\n    row.addEventListener('pointerleave', () => { rec.hover = false; });\n    // keyboard: arrows nudge it\n    row.addEventListener('keydown', (e) => {\n      if (e.key === 'ArrowLeft') { rec.fling = -900; kick(); } else if (e.key === 'ArrowRight') { rec.fling = 900; kick(); }\n    });\n\n    new IntersectionObserver(([e]) => { rec.visible = e.isIntersecting; kick(); }).observe(row);\n    // variables are live: a style / class change on the row (or its parent) re-reads them\n    const mo = new MutationObserver(cfg);\n    mo.observe(row, { attributes: true, attributeFilter: ['style', 'class'] });\n    if (row.parentElement) mo.observe(row.parentElement, { attributes: true, attributeFilter: ['style', 'class'] });\n    row.vsVelocityMarquee = rec;\n    rows.add(rec);\n  }\n\n  // one shared clock: page scroll velocity (or wheel, on pages that can't scroll) drives every row\n  let raf = 0, last = 0, lastY = scrollY, vel = 0, velS = 0, velV = 0, wheel = 0, flip = 1;\n  addEventListener('wheel', (e) => { if (document.documentElement.scrollHeight <= innerHeight + 1) { wheel += e.deltaY; kick(); } }, { passive: true });\n  addEventListener('scroll', kick, { passive: true });\n\n  function frame(now) {\n    const dt = Math.min(64, now - (last || now)) / 1000; last = now;\n    if (dt > 0) {\n      const y = scrollY; vel = (y - lastY + wheel) / dt; lastY = y; wheel = 0;\n      // spring-smoothed velocity so a single wheel notch reads as a push, not a spike\n      for (let h = 0; h < dt; h += 1 / 120) { const s = Math.min(1 / 120, dt - h); velV += (-140 * (velS - vel) - 24 * velV) * s; velS += velV * s; }\n    }\n    let any = false;\n    rows.forEach((r) => {\n      if (!r.visible) return; any = true;\n      const { speed, boost, skew, hover } = r.cfg;\n      const x0 = r.x;\n      if (!reduced) {\n        if (Math.abs(velS) > 40) flip = velS > 0 ? 1 : -1;             // every row turns with the scroll\n        const want = 1 + Math.min(Math.abs(velS) / 260, 9) * boost;\n        r.mult += (want - r.mult) * (1 - Math.exp(-dt * 6));\n        r.hoverF += ((r.hover ? hover : 1) - r.hoverF) * (1 - Math.exp(-dt * 5));\n        if (!r.drag) r.x += speed * r.dir * flip * r.mult * r.hoverF * dt;\n      }\n      if (!r.drag && r.fling) { r.x += r.fling * dt; r.fling *= Math.exp(-dt * 3.2); if (Math.abs(r.fling) < 5) r.fling = 0; }\n      if (!reduced && dt > 0) {\n        // lean into the row's own motion: top edge leads\n        const rowV = (r.x - x0) / dt;\n        const sk = Math.max(-skew, Math.min(skew, (Math.abs(rowV) > speed * 1.2 ? rowV - Math.sign(rowV) * speed * 1.2 : 0) * .018));\n        for (let h = 0; h < dt; h += 1 / 120) { const st = Math.min(1 / 120, dt - h); r.skewV += (-200 * (r.skew - sk) - 22 * r.skewV) * st; r.skew += r.skewV * st; }\n      }\n      const w = r.setW; r.x = ((r.x % w) + w) % w;\n      r.track.style.transform = `translate3d(${(-r.x).toFixed(2)}px,0,0) skewX(${r.skew.toFixed(3)}deg)`;\n      const a = (r.x / w) * 360;\n      for (const s of r.spins) s.style.transform = `rotate(${a.toFixed(1)}deg)`;\n    });\n    raf = any && (!reduced || rowsBusy()) ? requestAnimationFrame(frame) : 0;\n  }\n  const rowsBusy = () => [...rows].some((r) => r.drag || r.fling);\n  function kick() { if (!raf) { last = 0; lastY = scrollY; raf = requestAnimationFrame(frame); } }\n\n  let rt = 0, lw = innerWidth;\n  addEventListener('resize', () => { clearTimeout(rt); rt = setTimeout(() => { if (innerWidth !== lw) { lw = innerWidth; rows.forEach((r) => r.layout()); } }, 150); });\n\n  const ready = Promise.race([document.fonts ? document.fonts.ready : Promise.resolve(), new Promise((r) => setTimeout(r, 2500))]);\n  window.VsVelocityMarquee = { mount: (el) => ready.then(() => mount(el)) };\n  const init = () => ready.then(() => { document.querySelectorAll('[data-vs-velocity-marquee]').forEach(mount); kick(); });\n  document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', init) : init();\n})();\n</script>\n\n<!-- usage -->\n<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link href=\"https://fonts.googleapis.com/css2?family=Syne:wght@800&display=swap\" rel=\"stylesheet\">\n<div style=\"background:#1f2fc4;color:#f4efe4;padding:10vh 0;font:800 clamp(3rem,9vw,8rem)/1 'Syne',sans-serif;text-transform:uppercase;letter-spacing:-.03em\">\n  <div class=\"vs-velocity-marquee\" data-vs-velocity-marquee aria-label=\"Risograph, letterpress, screenprint\">\n    <div class=\"vs-velocity-marquee-track\">\n      <span>Risograph</span>\n      <svg class=\"vs-velocity-marquee-spin\" viewBox=\"0 0 40 40\" width=\".6em\" height=\".6em\" fill=\"#ff6fb5\" aria-hidden=\"true\"><path d=\"M20 0l4 14 14-4-10 10 10 10-14-4-4 14-4-14-14 4 10-10L2 10l14 4z\"/></svg>\n      <span>Letterpress</span>\n      <svg class=\"vs-velocity-marquee-spin\" viewBox=\"0 0 40 40\" width=\".6em\" height=\".6em\" fill=\"#ff6fb5\" aria-hidden=\"true\"><path d=\"M20 0l4 14 14-4-10 10 10 10-14-4-4 14-4-14-14 4 10-10L2 10l14 4z\"/></svg>\n      <span>Screenprint</span>\n      <svg class=\"vs-velocity-marquee-spin\" viewBox=\"0 0 40 40\" width=\".6em\" height=\".6em\" fill=\"#ff6fb5\" aria-hidden=\"true\"><path d=\"M20 0l4 14 14-4-10 10 10 10-14-4-4 14-4-14-14 4 10-10L2 10l14 4z\"/></svg>\n    </div>\n  </div>\n</div>\n"
    }
  ],
  "meta": {
    "category": "ui",
    "group": "Text & type",
    "tags": [
      "marquee",
      "ticker",
      "scroll velocity",
      "skew",
      "drag",
      "infinite scroll",
      "kinetic type"
    ],
    "tech": [
      "CSS",
      "JS"
    ],
    "deps": [
      "https://fonts.googleapis.com/css2?family=Syne:wght@800&display=swap"
    ],
    "source": null,
    "free": true,
    "accent": "#ff5b1f",
    "added": "2026-09-30",
    "demo": "https://vividsites.app/api/component/velocity-marquee/index.html",
    "thumbnail": "https://vividsites.app/api/component/velocity-marquee/thumb.jpg",
    "video": "https://vividsites.app/api/component/velocity-marquee/thumb.mp4",
    "page": "https://vividsites.app/c/velocity-marquee"
  }
}