デモ
デモを読み込めませんでした。
仕様
- 用途
- セクション
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
取引先のロゴを白いタイルに入れて格子に並べ、1 秒ごとにタイルを 1 枚選んで縦にめくります。タイルは奥へ倒れ、真横になった所で中のロゴを控えのロゴと入れ替えて、裏側から起き上がってきます。外したロゴは控えに戻すので、同じロゴが 2 つ並ぶことはありません。並べきれない数の取引先を、同じ場所で順に見せられます。
めくる時間は 0.9 秒です。倒れる前半は動き出しをゆっくり、起き上がる後半は着く所をゆっくりにして(sine.in と sine.out)、真横を過ぎるときがいちばん速く見えるようにしています。遠近の距離(transformPerspective)を 520px にすると、倒れる縁が手前へ少しせり出して、板が回っているように見えます。倒れるほど濃くなる陰を重ね(真横で 45%)、白いタイルでも向きが変わったことが分かるようにしています。
控えのロゴは template の中に置いています。中の画像は表示も読み込みもされず、読み上げにも出ませんが、めくった瞬間に初めて読み込むと、起き上がったタイルが空のまま見えてしまいます。はじめに画像だけを先読みしておきます。めくるタイルは、最近めくった 3 枚を外して乱数で選びます。外さないと、同じ所が続けてめくれて、壁の一部だけが動いているように見えます。
5 秒より長く動き続けるものには、止める手段が要ります。壁にポインタを重ねると、めくる間隔を刻むトゥイーンの timeScale を 0.4 秒で 0 へ落として止め、離すと動き出します。キーボードや指で操作する人のために、一時停止のボタンも置いています。動きを減らす設定では、めくらずに最初の並びのままにします。
つまみは、めくる間隔と時間、陰の濃さ、続けて選ばない枚数です。間隔を 0.5 秒まで詰めると壁全体がざわついて見え、3 秒より空けると止まっているように見えます。ロゴを帯にして流すやり方と違い、格子の位置が変わらないので、取引先の多い導入実績の節や、メディア掲載・受賞歴の一覧に向いています。
コード
<section class="logo-wall" aria-labelledby="logo-wall-title">
<p class="logo-wall__eyebrow">Customers</p>
<h2 class="logo-wall__title" id="logo-wall-title">多くのチームに<wbr>選ばれています</h2>
<p class="logo-wall__lead">ここに説明文が入ります。</p>
<!-- 並んでいるロゴ。めくれるのは .logo-wall__card で、中の img が入れ替わる -->
<ul class="logo-wall__grid">
<li><div class="logo-wall__card"><img src="/logos/aldermoor.svg" alt="Aldermoor" width="160" height="40"></div></li>
<li><div class="logo-wall__card"><img src="/logos/brightwell.svg" alt="Brightwell" width="160" height="40"></div></li>
<li><div class="logo-wall__card"><img src="/logos/corvine.svg" alt="Corvine" width="160" height="40"></div></li>
<li><div class="logo-wall__card"><img src="/logos/dunmere.svg" alt="dunmere" width="160" height="40"></div></li>
<li><div class="logo-wall__card"><img src="/logos/everloop.svg" alt="Everloop" width="160" height="40"></div></li>
<li><div class="logo-wall__card"><img src="/logos/fernhill.svg" alt="Fernhill & Co" width="160" height="40"></div></li>
<li><div class="logo-wall__card"><img src="/logos/glasswing.svg" alt="Glasswing" width="160" height="40"></div></li>
<li><div class="logo-wall__card"><img src="/logos/halden.svg" alt="Halden" width="160" height="40"></div></li>
<li><div class="logo-wall__card"><img src="/logos/ironbark.svg" alt="Ironbark" width="160" height="40"></div></li>
<li><div class="logo-wall__card"><img src="/logos/juniper.svg" alt="juniper" width="160" height="40"></div></li>
<li><div class="logo-wall__card"><img src="/logos/kestrow.svg" alt="Kestrow" width="160" height="40"></div></li>
<li><div class="logo-wall__card"><img src="/logos/lumenrow.svg" alt="Lumenrow" width="160" height="40"></div></li>
</ul>
<!-- 控えのロゴ。template の中は表示も読み込みもされない。めくるたびに、タイルのロゴと 1 つずつ入れ替わる -->
<template class="logo-wall__pool">
<img src="/logos/marlowe.svg" alt="Marlowe" width="160" height="40">
<img src="/logos/northvale.svg" alt="Northvale" width="160" height="40">
<img src="/logos/oakhaven.svg" alt="oakhaven" width="160" height="40">
<img src="/logos/pellucid.svg" alt="Pellucid" width="160" height="40">
<img src="/logos/quillon.svg" alt="Quillon" width="160" height="40">
<img src="/logos/rookery.svg" alt="rookery" width="160" height="40">
<img src="/logos/saltmarsh.svg" alt="Saltmarsh" width="160" height="40">
<img src="/logos/tallis.svg" alt="Tallis" width="160" height="40">
</template>
<div class="logo-wall__foot">
<a class="logo-wall__link" href="/customers/">導入事例を見る →</a>
<!-- 5 秒より長く動き続けるので、止める手段を置く。めくるときだけ JS が出す -->
<button class="logo-wall__toggle" type="button" aria-pressed="false" hidden>一時停止</button>
</div>
</section>
.logo-wall {
--bg: #f2f1ed;
--surface: #ffffff;
--ink: #262626;
--mute: #6b6b6b;
--card-height: 104px;
padding: 96px 24px;
background: var(--bg);
color: var(--ink);
text-align: center;
}
.logo-wall__eyebrow {
margin: 0 0 16px;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--mute);
}
.logo-wall__title {
margin: 0 0 16px;
font-size: clamp(28px, 4vw, 44px);
font-weight: 800;
line-height: 1.1; letter-spacing: -0.02em;
word-break: keep-all; /* 折り返すのは HTML の <wbr> の所だけ */
}
.logo-wall__lead { margin: 0 0 40px; font-size: 18px; line-height: 1.8; letter-spacing: 0.04em; }
.logo-wall__grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
max-width: 1088px;
margin: 0 auto;
padding: 0;
list-style: none;
}
.logo-wall__card {
position: relative;
display: grid;
place-items: center;
height: var(--card-height);
border-radius: 4px;
background: var(--surface);
}
/* めくれるタイルの陰。--shade を JS が 0〜0.45 で動かす */
.logo-wall__card::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background: rgb(38 38 38 / var(--shade, 0));
}
.logo-wall__card img { display: block; width: auto; max-width: 80%; height: 32px; }
.logo-wall__foot {
display: flex;
justify-content: space-between;
align-items: center;
max-width: 1088px;
margin: 32px auto 0;
}
.logo-wall__link {
color: inherit;
font-size: 15px;
font-weight: 600;
letter-spacing: 0.04em;
text-underline-offset: 0.4em;
}
.logo-wall__toggle {
height: 40px;
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;
}
.logo-wall__toggle[aria-pressed="true"] { background: var(--ink); color: var(--bg); }
.logo-wall :focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }
@media (max-width: 720px) {
.logo-wall { --card-height: 80px; }
.logo-wall__grid { grid-template-columns: repeat(2, 1fr); }
}
import { gsap } from "gsap";
const STEP = 1; // めくりの間隔(秒)
const FLIP = 0.9; // 1 枚がめくれる時間(秒)
const SHADE = 0.45; // 真横まで倒れたときの陰の濃さ
const REST = 3; // めくったタイルを、次に選ばないでおく枚数
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
document.querySelectorAll(".logo-wall").forEach((wall) => {
// 動きを減らす設定では、めくらずに最初の並びのままにする
if (reduceMotion) return;
const grid = wall.querySelector(".logo-wall__grid");
const cards = [...wall.querySelectorAll(".logo-wall__card")];
const pool = [...wall.querySelector(".logo-wall__pool").content.children];
const toggle = wall.querySelector(".logo-wall__toggle");
const recent = [];
let hovering = false;
let paused = false;
// 控えの画像は template の中では読み込まれない。めくった瞬間に空にならないよう、先に読んでおく
pool.forEach((img) => (new Image().src = img.getAttribute("src")));
gsap.set(cards, { transformPerspective: 520 });
function flipOne() {
const choices = cards.filter((card) => !recent.includes(card));
const card = choices[Math.floor(Math.random() * choices.length)];
recent.push(card);
if (recent.length > REST) recent.shift();
const next = pool.splice(Math.floor(Math.random() * pool.length), 1)[0];
gsap
.timeline()
// 奥へ倒れ、真横になった所で中身を入れ替えて、裏から起き上がる
.to(card, { rotationX: 90, "--shade": SHADE, duration: FLIP / 2, ease: "sine.in" })
.add(() => {
pool.push(card.firstElementChild);
card.replaceChildren(next);
})
.fromTo(card, { rotationX: -90 }, { rotationX: 0, "--shade": 0, duration: FLIP / 2, ease: "sine.out" });
}
// 間隔を刻むだけのトゥイーン。timeScale を 0 にすると次のめくりが来なくなる(めくりかけは最後までめくる)
const loop = gsap.to({}, { duration: STEP, repeat: -1, onRepeat: flipOne });
const update = () => gsap.to(loop, { timeScale: hovering || paused ? 0 : 1, duration: 0.4, overwrite: true });
grid.addEventListener("pointerenter", () => {
hovering = true;
update();
});
grid.addEventListener("pointerleave", () => {
hovering = false;
update();
});
// キーボードや指でも止められるボタン。名前は変えず、押されているかを aria-pressed で伝える
toggle.hidden = false;
toggle.addEventListener("click", () => {
paused = !paused;
toggle.setAttribute("aria-pressed", String(paused));
update();
});
});