ポイントは「1セット分の幅(または高さ)」をJSで正確に計測し、CSS変数 --marquee-step に流し込むこと。
CSS側はその距離分だけ transform するだけで、計算不要の無限スクロールが成立します。
HTML
<div class="marquee">
<div class="marquee__inner">
<div class="marquee__track js-marquee">
<span class="marquee__item">コンテンツA</span>
<span class="marquee__item">コンテンツB</span>
<span class="marquee__item">コンテンツC</span>
</div>
</div>
</div>
SCSS
GPUアクセラレーションを有効にするため、will-change プロパティを追加しています。
.marquee {
overflow: hidden;
// 必要に応じて幅や高さを指定
width: 100%;
}
.marquee__inner {
overflow: hidden;
width: 100%;
height: 100%;
}
.marquee__track {
display: flex;
align-items: center;
width: fit-content;
gap: 12px;
// 初期状態ではアニメーションさせない(JSで計算後に開始)
animation: none;
will-change: transform;
--marquee-duration: 20s;
--marquee-clone-length: 2;
--marquee-step: 0px; // JSで上書き
&.is-vertical {
flex-direction: column;
width: 100%; // 縦の場合は幅を埋める
height: fit-content;
}
}
.marquee__item {
flex-shrink: 0;
white-space: nowrap;
}
/* 横スクロール */
@keyframes marquee-horizontal {
0% {
transform: translateX(0);
}
100% {
transform: translateX(calc(-1 * var(--marquee-step)));
}
}
/* 縦スクロール */
@keyframes marquee-vertical {
0% {
transform: translateY(0);
}
100% {
transform: translateY(calc(-1 * var(--marquee-step)));
}
}
JS
/**
* 無限マーキー初期化
* - track内の要素を複製して画面を埋める
* - 1セット分の幅/高さを --marquee-step に設定
* - CSS keyframes でアニメーション実行
*/
const initMarquee = (selector = ".js-marquee") => {
const tracks = document.querySelectorAll(selector);
if (!tracks.length) return;
const setupTrack = (track) => {
// リサイズ時の再計算用に初期HTMLを保持
const baseHtml = track.dataset.marqueeBaseHtml || track.innerHTML;
if (!track.dataset.marqueeBaseHtml) {
track.dataset.marqueeBaseHtml = baseHtml;
}
track.innerHTML = baseHtml;
const items = Array.from(track.children);
if (!items.length) return;
const container = track.parentElement;
if (!container) return;
const isVertical = track.classList.contains("is-vertical");
const axis = isVertical ? "y" : "x";
// コンテナとコンテンツのサイズ計測
// 縦向きの場合は高さを、横向きの場合は幅を基準にする
const containerSize = axis === "x" ? container.clientWidth : container.clientHeight;
let contentSize = axis === "x" ? track.scrollWidth : track.scrollHeight;
// コンテンツがコンテナの2倍以上になるまで複製(最低1回は複製)
let cloneCount = 0;
const maxClones = 12; // 無限ループ防止
while ((contentSize < containerSize * 2 || cloneCount < 1) && cloneCount < maxClones) {
items.forEach((item) => {
const clone = item.cloneNode(true);
clone.setAttribute("aria-hidden", "true"); // アクセシビリティ対応
track.appendChild(clone);
});
cloneCount++;
// 複製後のサイズを再取得
contentSize = axis === "x" ? track.scrollWidth : track.scrollHeight;
}
// 1セット分の移動距離を算出
const cloneLength = cloneCount + 1;
track.style.setProperty("--marquee-clone-length", String(cloneLength));
if (contentSize > 0) {
const stepSize = contentSize / cloneLength;
track.style.setProperty("--marquee-step", `${stepSize}px`);
}
// アニメーション適用
const animationName = isVertical ? "marquee-vertical" : "marquee-horizontal";
track.style.animation = `${animationName} var(--marquee-duration) linear infinite`;
track.dataset.marqueeInitialized = "true";
};
const setupAll = () => tracks.forEach(setupTrack);
// フォント読み込み完了後に計算(ズレ防止)
if (document.fonts && document.fonts.ready) {
document.fonts.ready.then(setupAll);
} else {
setupAll();
}
// リサイズ対応
let resizeTimer;
window.addEventListener("resize", () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(setupAll, 200);
});
};
window.addEventListener("DOMContentLoaded", () => {
initMarquee();
});
運用・実装のポイント
- 縦スクロール対応
HTMLのjs-marquee要素にis-verticalクラスを付与するだけで縦方向に切り替わります。 - 速度調整
CSS変数の--marquee-duration(例:20s)を変更することで、JSを触らずに速度調整が可能です。 - アクセシビリティ
複製された要素には自動的にaria-hidden="true"が付与されるため、スクリーンリーダーによる読み上げの重複を防ぎます。 - パフォーマンス
transformプロパティによる移動とwill-changeの指定により、描画負荷を抑えたスムーズな動作を実現しています。