デモ
デモを読み込めませんでした。
仕様
- 用途
- カード
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
カードを敷き詰めた帯を横に流しながら、それぞれのカードの位置を modifiers で折り返します。左端から出たカードは右端の外に回り込むので、複製を作らなくても途切れずに流れ続けます。
流す速さは 1 秒に 60px で、カードの並びと間隔から 1 周にかかる時間を決めています。止まる・動き出すまでの時間は 0.6 秒で、これより短くすると急に止まって見えます。
折り返す範囲は「表示幅 + カード 1 枚ぶん」以上が必要です。足りないと、回り込んだカードが画面の中に現れてしまいます。カードの幅と間隔は、CSS で決めた値を実測して使い、JS には書き込みません。
ホバーすると流れる速さがゆっくり落ちて読みやすくなり、離れると元の速さに戻ります。キーボードや指で操作する人のために、止めるボタンも用意しています。
記事・事例・お客様の声のような、横に流し続けたいカード列に向いています。カードの枚数や幅がそろっていなくても使えます。
コード
<section class="carousel" aria-labelledby="carousel-title">
<h2 class="carousel__title" id="carousel-title">読みもの</h2>
<ul class="carousel__track">
<li class="carousel__card">
<img class="carousel__image" src="/images/journal-travel.jpg" alt="" width="336" height="240">
<p class="carousel__tag">旅</p>
<h3 class="carousel__card-title">静かな町で過ごす、12時間</h3>
<p class="carousel__time">6分で読めます</p>
</li>
<li class="carousel__card">
<img class="carousel__image" src="/images/journal-design.jpg" alt="" width="336" height="240">
<p class="carousel__tag">デザイン</p>
<h3 class="carousel__card-title">いまも紙にスケッチする理由</h3>
<p class="carousel__time">4分で読めます</p>
</li>
<li class="carousel__card">
<img class="carousel__image" src="/images/journal-food.jpg" alt="" width="336" height="240">
<p class="carousel__tag">食</p>
<h3 class="carousel__card-title">朝ごはんは、ゆっくりと</h3>
<p class="carousel__time">5分で読めます</p>
</li>
<li class="carousel__card">
<img class="carousel__image" src="/images/journal-work.jpg" alt="" width="336" height="240">
<p class="carousel__tag">働き方</p>
<h3 class="carousel__card-title">週4日勤務で、わかったこと</h3>
<p class="carousel__time">8分で読めます</p>
</li>
<li class="carousel__card">
<img class="carousel__image" src="/images/journal-craft.jpg" alt="" width="336" height="240">
<p class="carousel__tag">ものづくり</p>
<h3 class="carousel__card-title">紙とのりと、根気の話</h3>
<p class="carousel__time">3分で読めます</p>
</li>
</ul>
<button class="carousel__toggle" type="button" aria-pressed="false" hidden>一時停止</button>
</section>
.carousel {
--bg: #f2f1ed;
--surface: #ffffff;
--ink: #262626;
--mute: #6b6b6b;
--line: rgba(38, 38, 38, 0.12);
padding: 64px clamp(16px, 6vw, 96px) 96px;
background: var(--bg);
color: var(--ink);
overflow: hidden;
}
.carousel__title {
margin: 0 0 40px;
font-size: clamp(28px, 4vw, 44px);
font-weight: 800;
line-height: 1.1;
letter-spacing: -0.02em;
}
.carousel__track {
display: flex;
flex-wrap: wrap;
gap: 32px;
margin: 0;
padding: 0;
list-style: none;
}
.carousel__card {
flex: none;
width: 336px;
border-radius: 4px;
overflow: hidden;
background: var(--surface);
box-shadow: inset 0 0 0 1px var(--line);
}
.carousel__image {
display: block;
width: 100%;
height: 240px;
object-fit: cover;
background: var(--line);
}
.carousel__tag {
width: fit-content;
margin: 24px 24px 0;
padding: 0 10px;
border-radius: 999px;
box-shadow: inset 0 0 0 1px var(--line);
color: var(--mute);
font-size: 12px;
font-weight: 700;
line-height: 24px;
letter-spacing: 0.06em;
white-space: nowrap;
}
.carousel__card-title {
margin: 16px 24px 0;
font-size: 22px;
font-weight: 700;
line-height: 1.3;
letter-spacing: -0.01em;
word-break: auto-phrase;
}
.carousel__time {
margin: 16px 24px 24px;
color: var(--mute);
font-size: 13px;
letter-spacing: 0.04em;
}
.carousel__toggle {
height: 40px;
margin-top: 32px;
padding: 0 20px;
border: 0;
border-radius: 999px;
box-shadow: inset 0 0 0 1px var(--ink);
background: transparent;
color: var(--ink);
font: inherit;
font-size: 14px;
font-weight: 600;
letter-spacing: 0.04em;
cursor: pointer;
}
.carousel__toggle[aria-pressed="true"] { background: var(--ink); color: var(--bg); }
.carousel__toggle:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }
import { gsap } from "gsap";
const SPEED = 60; // 1 秒に流れる距離(px)
const EASE_TIME = 0.6; // 止まる・動き出すまでの時間(秒)
const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
document.querySelectorAll(".carousel").forEach((section) => {
const track = section.querySelector(".carousel__track");
const toggle = section.querySelector(".carousel__toggle");
const cards = [...track.children];
// 動きを減らす設定では流さず、折り返して全部を並べたままにする
if (reduceMotion) return;
// 間隔は CSS の gap から読む。寸法を JS に直書きしない
const gap = parseFloat(getComputedStyle(track).gap) || 0;
const pitch = cards[0].getBoundingClientRect().width + gap;
const total = pitch * cards.length; // 1 組の幅(表示幅 + カード 1 枚ぶん以上にする)
const wrapX = gsap.utils.wrap(-pitch, total - pitch);
track.style.position = "relative";
track.style.height = `${cards[0].offsetHeight}px`;
cards.forEach((card, i) => {
card.style.position = "absolute";
card.style.left = "0";
card.style.top = "0";
gsap.set(card, { x: i * pitch });
});
// 各カードの x を「1 組の幅」で折り返す。左端から出たカードは右端の外へ回り込む
const loop = gsap.to(cards, {
x: `-=${total}`,
duration: total / SPEED,
ease: "none",
repeat: -1,
modifiers: { x: (x) => `${wrapX(parseFloat(x))}px` },
});
const ease = (to) => gsap.to(loop, { timeScale: to, duration: EASE_TIME, ease: "power2.out", overwrite: true });
let paused = false;
track.addEventListener("pointerenter", () => ease(0));
track.addEventListener("pointerleave", () => ease(paused ? 0 : 1));
// キーボードでも押せる止めるボタン
toggle.hidden = false;
toggle.addEventListener("click", () => {
paused = !paused;
toggle.setAttribute("aria-pressed", String(paused));
ease(paused ? 0 : 1);
});
});