Back to index

16 Marquee (scroll velocity)

Motion · Scroll-linked
Marquee (scroll velocity)

A still — with scripts on, it runs right here

Take it with you

Copy and paste, or save a file and hand it over. For a machine with no network, one “Everything” file is enough.

Everything

motion-marquee-everything.md

Everything in this panel, including the contents behind each URL, in one Markdown file

Prompt

952 B
Marquee (scroll velocity) — Marquee (scroll velocity)

Place two bands of text, one above the other, inside a 100vh sticky box, each in an overflow-hidden frame with large heading type (40–72px). The upper band moves left and the lower band moves right, both at a base speed of 60px per second and never stopping.
Each frame, add three times that frame's scroll distance to a speed boost; the boost decays to 2% of its value every second, so scrolling briefly speeds the bands up.
Lay out two identical copies of the items and wrap the offset by the width of one copy so the loop has no seam.
Animate only transform with requestAnimationFrame; no libraries.

Do not drop:
- The bands never stop; scrolling only adds speed
- Two identical copies keep the loop seamless
- With reduced motion, hold the bands still at the start position

A working example (one self-contained HTML file): https://design-vault.pages.dev/designs/motion-marquee/demo.html

Files

demo.html

9.0 KB

/designs/motion-marquee/demo.html — One self-contained HTML file. CSS and plain JavaScript only; it fetches nothing.

Show contents
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>16 マーキー — Marquee (scroll velocity)</title>
<style>
/* dv-motion-base:start */
:root {
  --ground: #FFFFFF;
  --surface: #F3F5F8;
  --ink: #14181F;
  --muted: #5A6472;
  --accent: #1F5FA8;
  --rule: #D8DDE4;
  --accent-soft: color-mix(in srgb, var(--accent) 12%, var(--ground));
  --accent-mid: color-mix(in srgb, var(--accent) 45%, var(--ground));
  --shade: color-mix(in srgb, var(--ink) 14%, transparent);

  --font: "Zen Kaku Gothic New", "Hiragino Sans", "Noto Sans JP", "Yu Gothic", system-ui, sans-serif;
  --mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;

  --t-label: 13px;
  --t-body: 16px;
  --t-lead: 20px;
  --t-h2: 32px;
  --t-h1: clamp(40px, 7vw, 72px);

  --s1: 4px;
  --s2: 8px;
  --s3: 16px;
  --s4: 24px;
  --s5: 40px;
  --s6: 64px;
  --s7: 104px;

  --radius: 12px;
  --ease: cubic-bezier(.2, .7, .2, 1);
  --dur: .7s;
  --measure: 1080px;
}
*, *::before, *::after { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
  margin: 0;
  background: var(--ground);
  color: var(--ink);
  font-family: var(--font);
  font-size: var(--t-body);
  line-height: 1.7;
  font-feature-settings: "palt";
  overflow-x: hidden;
}
h1, h2, h3, p, ul, ol, figure { margin: 0; padding: 0; }
ul, ol { list-style: none; }
.dv-wrap { width: min(100% - 2 * var(--s4), var(--measure)); margin-inline: auto; }
.dv-label {
  min-height: 72vh;
  display: grid;
  align-content: center;
  gap: var(--s3);
  padding-block: var(--s6);
  border-bottom: 1px solid var(--rule);
}
.dv-num { font-family: var(--mono); font-size: var(--t-lead); font-weight: 700; color: var(--accent); letter-spacing: .04em; }
.dv-name { font-size: var(--t-h1); font-weight: 700; line-height: 1.15; letter-spacing: .01em; }
.dv-ai { font-family: var(--mono); font-size: var(--t-lead); color: var(--muted); }
.dv-desc { font-size: var(--t-lead); color: var(--ink); }
.dv-hint { font-size: var(--t-label); color: var(--muted); letter-spacing: .12em; margin-top: var(--s4); }
.dv-stage { padding-block: var(--s7); }
.dv-h2 { font-size: var(--t-h2); font-weight: 700; line-height: 1.3; }
.dv-lead { font-size: var(--t-lead); color: var(--muted); }
.dv-eyebrow { font-family: var(--mono); font-size: var(--t-label); color: var(--accent); letter-spacing: .12em; text-transform: uppercase; }
.dv-card { background: var(--surface); border: 1px solid var(--rule); border-radius: var(--radius); padding: var(--s4); }
.dv-ph {
  border-radius: var(--radius);
  background: linear-gradient(135deg, var(--accent-soft), var(--accent-mid) 60%, var(--accent));
  aspect-ratio: 16 / 9;
}
.dv-ph--alt { background: linear-gradient(200deg, var(--surface), var(--accent-soft) 55%, var(--accent-mid)); }
.dv-ph--ink { background: linear-gradient(160deg, var(--ink), color-mix(in srgb, var(--ink) 55%, var(--accent))); }
.dv-end {
  min-height: 60vh;
  display: grid;
  place-items: center;
  border-top: 1px solid var(--rule);
  color: var(--muted);
  font-family: var(--mono);
  font-size: var(--t-label);
  letter-spacing: .12em;
}
/* dv-motion-base:end */

/* ---- この演出だけの CSS。色は var(--…) か color-mix() のみ ---- */
.marquee-zone { height: 300vh; }
.marquee-stick { position: sticky; top: 0; height: 100vh; display: grid; align-content: center; gap: var(--s5); }
.marquee { overflow: hidden; white-space: nowrap; border-block: 1px solid var(--rule); }
.marquee--alt { background: var(--surface); }
.marquee-track { display: flex; width: max-content; will-change: transform; }
.marquee-item { padding-inline: var(--s5); padding-block: var(--s3); font-size: var(--t-h1); font-weight: 700; line-height: 1.3; color: var(--ink); }
.marquee--alt .marquee-item { color: color-mix(in srgb, var(--ink) 55%, var(--ground)); }
.marquee-note { padding-inline: var(--s3); font-family: var(--mono); font-size: var(--t-label); color: var(--muted); letter-spacing: .12em; }

@media (prefers-reduced-motion: reduce) {
  .marquee-track { transform: none; }
}
</style>
</head>
<body data-motion-number="16">
<header class="dv-label">
  <div class="dv-wrap">
    <p class="dv-num">16</p>
    <h1 class="dv-name">マーキー</h1>
    <p class="dv-ai">Marquee (scroll velocity)</p>
    <p class="dv-desc">流れ続け、スクロールで加速</p>
    <p class="dv-hint">SCROLL ↓</p>
  </div>
</header>

<main class="dv-stage">
  <section class="marquee-zone">
    <div class="marquee-stick">
      <div class="marquee" data-dir="left">
        <div class="marquee-track">
          <span class="marquee-item">見出しが入ります</span>
          <span class="marquee-item">見出しが入ります</span>
          <span class="marquee-item">見出しが入ります</span>
          <span class="marquee-item">見出しが入ります</span>
        </div>
      </div>
      <p class="marquee-note">スクロールすると流れが速くなります</p>
      <div class="marquee marquee--alt" data-dir="right">
        <div class="marquee-track">
          <span class="marquee-item">本文のダミーです</span>
          <span class="marquee-item">本文のダミーです</span>
          <span class="marquee-item">本文のダミーです</span>
          <span class="marquee-item">本文のダミーです</span>
        </div>
      </div>
    </div>
  </section>
</main>

<footer class="dv-end">END — 16 マーキー</footer>

<script>
/* dv-motion-helpers:start */
const dvReduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
document.documentElement.classList.add('js');

/**
 * 画面に入った要素へ .is-in を付ける。
 * data-reveal="repeat" の要素は画面を出たら外す(何度でも再生)。
 */
function dvReveal(selector = '[data-reveal]', { threshold = 0.2, rootMargin = '0px 0px -8% 0px' } = {}) {
  const els = [...document.querySelectorAll(selector)];
  if (dvReduce || !('IntersectionObserver' in window)) {
    els.forEach((el) => el.classList.add('is-in'));
    return;
  }
  const io = new IntersectionObserver((entries) => {
    for (const e of entries) {
      if (e.isIntersecting) {
        e.target.classList.add('is-in');
        if (e.target.dataset.reveal !== 'repeat') io.unobserve(e.target);
      } else if (e.target.dataset.reveal === 'repeat') {
        e.target.classList.remove('is-in');
      }
    }
  }, { threshold, rootMargin });
  els.forEach((el) => io.observe(el));
}

/**
 * スクロール量を 0〜1 にして、各 [data-progress] 要素の CSS 変数 --p に書く。
 *   data-progress="through" : 要素の上端が画面の下端に来たとき 0、下端が画面の上端を抜けたとき 1
 *   data-progress="pin"     : 要素の上端が画面の上端に来たとき 0、下端が画面の下端に来たとき 1
 *                             (中に position: sticky の箱を持つ、背の高い区画用)
 *   data-progress="page"    : ページ全体のスクロール量
 * onUpdate(el, p) を渡すと、値が変わるたびに呼ばれる。
 */
function dvProgress(onUpdate) {
  const els = [...document.querySelectorAll('[data-progress]')];
  const clamp = (n) => Math.min(1, Math.max(0, n));
  let queued = false;
  const measure = () => {
    queued = false;
    const vh = window.innerHeight;
    for (const el of els) {
      const r = el.getBoundingClientRect();
      let p;
      if (el.dataset.progress === 'pin') p = r.height > vh ? -r.top / (r.height - vh) : 0;
      else if (el.dataset.progress === 'page') {
        const max = document.documentElement.scrollHeight - vh;
        p = max > 0 ? window.scrollY / max : 0;
      } else p = (vh - r.top) / (vh + r.height);
      p = clamp(p);
      el.style.setProperty('--p', p.toFixed(4));
      if (onUpdate) onUpdate(el, p);
    }
  };
  const request = () => {
    if (!queued) { queued = true; requestAnimationFrame(measure); }
  };
  addEventListener('scroll', request, { passive: true });
  addEventListener('resize', request);
  measure();
}
/* dv-motion-helpers:end */

/* ---- この演出だけの JS ---- */
// 帯の中身を 2 組に増やし、組の幅ぶん流れたら先頭へ戻す。
const bands = [...document.querySelectorAll('.marquee')].map((el) => {
  const track = el.querySelector('.marquee-track');
  track.append(...[...track.children].map((c) => c.cloneNode(true)));
  return { track, dir: el.dataset.dir === 'right' ? 1 : -1, x: 0 };
});

// 基本の速さ(60px/s)に、スクロールの速さで足した分を減衰させながら乗せる。
let lastY = window.scrollY;
let boost = 0;
let last = performance.now();
const frame = (now) => {
  const dt = Math.min(0.05, (now - last) / 1000);
  last = now;
  const dy = Math.abs(window.scrollY - lastY);
  lastY = window.scrollY;
  boost = boost * Math.pow(0.02, dt) + dy * 3;
  for (const b of bands) {
    const half = b.track.scrollWidth / 2;
    b.x += b.dir * (60 + boost) * dt;
    b.x = ((b.x % half) + half) % half - half;
    b.track.style.transform = `translate3d(${b.x.toFixed(2)}px, 0, 0)`;
  }
  requestAnimationFrame(frame);
};
if (!dvReduce) requestAnimationFrame(frame);
</script>
</body>
</html>

/ai/assets/motion-marquee.json