デモ
デモを読み込めませんでした。
仕様
- 用途
- セクション
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
大きさの違うタイルを格子に組んだベントグリッドを、画面に入った順に 1 枚ずつ開いて見せます。タイルは clip-path の inset で下の縁から上へ開き、中の文字は 32px 下から、写真は少し寄った所から元の大きさへ戻ります。開いていく窓を中身が少し遅れて追うので、タイルの中から湧き上がってくるように見えます。
開くのは 0.9 秒の power3.out で、中身は 0.08 秒遅らせて同じ速さで上げます。写真の寄りは 118% から 1.2 秒かけて戻し、開ききったあとも少しだけ動きを残します。同じときに線を越えたタイルは、並べた順に 0.12 秒ずつずらすので、横に並んだタイルが左から順に開きます。間隔を 0.2 秒より広げると、最後のタイルを待つ間が目立ちます。
出すのは画面に入った 1 回だけなので、スクロールの位置を追い続ける ScrollTrigger は要らず、ブラウザの IntersectionObserver で足ります。rootMargin で画面の下を 15% 縮めておくと、タイルの上端がその線を越えたときに知らせが届きます。同じときに越えたタイルは 1 回の知らせにまとまって届くので、その中の順番でずらせば、まとめて出す形になります。出したタイルは監視から外します。
隠すのは clip-path だけなので、出る前のタイルの文も読み上げには届きます。visibility や display で隠すと、出る前の中身が読み上げから外れてしまいます。隠す値はスクリプトが入れるので、スクリプトが動かないときや動きを減らす設定では、タイルは最初から出たまま見えます。ページの途中で読み込み直したときは、画面より上のタイルは隠れたままで、戻ってきて画面に入ったときに開きます。
つまみは、開く時間とずらす間隔、中身が上がる距離、知らせを受ける線の高さです。タイルの大きさは格子の span で決めるので、並べ替えても出る順は並べた順のままです。サービス紹介や実績のまとめ、料金プランの特長のように、大きさに強弱をつけて並べたい節に向いています。
コード
<!-- タイルの大きさは tile--feature(2×2)・tile--wide(2×1)・tile--tall(1×2)で決める。出る順は、並べた順 -->
<section class="bento" aria-labelledby="bento-title">
<div class="bento__head">
<p class="bento__eyebrow">What we do</p>
<h2 class="bento__title" id="bento-title"><span>公開に必要なすべてを、</span><span>ひとつのチームで。</span></h2>
<p class="bento__lead">ここに説明文が入ります。</p>
</div>
<ul class="bento__grid">
<li class="tile tile--feature tile--photo">
<img class="tile__image" src="/img/brand-sites.jpg" alt="" width="1072" height="832">
<div class="tile__body">
<h3 class="tile__title">ブランドサイト</h3>
<p class="tile__text">ここに説明文が入ります。</p>
</div>
</li>
<li class="tile tile--wide">
<div class="tile__body">
<h3 class="tile__title">デザインシステム</h3>
<p class="tile__text">ここに説明文が入ります。</p>
</div>
</li>
<li class="tile tile--dark">
<div class="tile__body">
<p class="tile__label">満足度</p>
<p class="tile__stat">4.9<small>/5</small></p>
<p class="tile__text">制作後のアンケート(5段階)</p>
</div>
</li>
<li class="tile tile--tall tile--photo">
<img class="tile__image" src="/img/motion.jpg" alt="" width="520" height="832">
<div class="tile__body">
<h3 class="tile__title">モーション</h3>
<p class="tile__text">ここに説明文が入ります。</p>
</div>
</li>
<li class="tile">
<div class="tile__body">
<h3 class="tile__title">検索対策</h3>
<p class="tile__text">ここに説明文が入ります。</p>
</div>
</li>
<li class="tile tile--wide tile--accent">
<div class="tile__body">
<h3 class="tile__title">運用と改善</h3>
<p class="tile__text">ここに説明文が入ります。</p>
</div>
</li>
</ul>
</section>
.bento {
--ink: #262626;
--surface: #ffffff;
--mute-small: #6b6b6b;
--dark: #262626; /* 数字を見せるタイル */
--dark-ink: #eeece6;
--accent: #d9ba92; /* 色の地のタイル(中身の色。上の文字は墨) */
padding: 96px 24px;
background: #f2f1ed;
color: var(--ink);
}
.bento__head { max-width: 640px; margin: 0 auto; text-align: center; }
.bento__eyebrow { margin: 0; font-size: 13px; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; color: var(--mute-small); }
.bento__title { margin: 16px 0 0; font-size: clamp(28px, 5vw, 44px); font-weight: 800; line-height: 1.1; letter-spacing: -0.02em; }
.bento__title span { display: block; }
/* 和文を文節の切れ目で折り返す(対応していないブラウザでは、ふつうの折り返しになる) */
.bento__lead,
.tile__text { word-break: auto-phrase; }
.bento__lead { margin: 24px 0 0; font-size: 18px; line-height: 1.8; letter-spacing: 0.04em; }
.bento__grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-auto-rows: 200px;
gap: 16px;
max-width: 1088px;
margin: 64px auto 0;
padding: 0;
list-style: none;
}
.tile {
position: relative;
display: flex;
flex-direction: column;
justify-content: flex-end;
overflow: hidden;
padding: 24px;
border-radius: 4px;
background: var(--surface);
}
.tile--feature { grid-column: span 2; grid-row: span 2; }
.tile--wide { grid-column: span 2; }
.tile--tall { grid-row: span 2; }
.tile--dark { background: var(--dark); color: var(--dark-ink); }
.tile--accent { background: var(--accent); }
.tile--photo { color: #ffffff; }
.tile__image { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
/* 写真の上の文字を読ませる幕(下ほど濃い) */
.tile--photo::after { content: ""; position: absolute; inset: 0; background: linear-gradient(0deg, rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0) 60%); }
.tile__body { position: relative; z-index: 1; }
.tile__title { margin: 0; font-size: 24px; font-weight: 700; line-height: 1.3; letter-spacing: -0.01em; }
.tile__text { margin: 8px 0 0; font-size: 14px; line-height: 1.7; letter-spacing: 0.04em; }
.tile:not(.tile--photo, .tile--dark, .tile--accent) .tile__text { color: var(--mute-small); }
.tile__stat { margin: 0; font-size: 64px; font-weight: 800; line-height: 1; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }
.tile__stat small { margin-left: 4px; font-size: 20px; font-weight: 700; color: #a8a6a0; }
.tile--dark .tile__text { font-size: 13px; color: #a8a6a0; }
/* 数字のタイルは、ラベルを上端、数字と説明を下端に置く */
.tile--dark .tile__body { flex: 1; display: flex; flex-direction: column; }
.tile__label { margin: 0 0 auto; font-size: 13px; font-weight: 700; line-height: 1; letter-spacing: 0.06em; color: #a8a6a0; }
/* 狭い画面では 2 列、さらに狭ければ 1 列にする(大きいタイルは 2 段ぶんの高さのまま) */
@media (max-width: 800px) {
.bento__grid { grid-template-columns: repeat(2, 1fr); grid-auto-rows: 180px; }
}
@media (max-width: 480px) {
.bento__grid { grid-template-columns: 1fr; }
.tile--feature,
.tile--wide { grid-column: auto; }
}
import { gsap } from "gsap";
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const STAGGER = 0.12; // 同じときに線を越えたタイルどうしの間隔(秒)
// タイルの出方: 下の縁から上へ開き(clip-path)、中身は少し下から、写真は少し寄った所から戻る
function reveal(tile) {
const tl = gsap
.timeline({ paused: true })
.fromTo(tile, { clipPath: "inset(100% 0% 0% 0%)" }, { clipPath: "inset(0% 0% 0% 0%)", duration: 0.9, ease: "power3.out" })
.from(tile.querySelector(".tile__body"), { y: 32, duration: 0.9, ease: "power3.out" }, 0.08);
const image = tile.querySelector(".tile__image");
if (image) tl.from(image, { scale: 1.18, duration: 1.2, ease: "power3.out" }, 0);
return tl;
}
document.querySelectorAll(".bento").forEach((section) => {
// 行末の約物(「、」「。」)は数えず、その手前までの字面の左右の端の中央を行の中央にそろえる。
// 約物は右へぶら下がる(含めて中央にすると、字が左へ寄って見える)。ずれは em で持ち、字の大きさが変わっても合う
const title = section.querySelector(".bento__title");
document.fonts.ready.then(() => {
const style = getComputedStyle(title), ruler = document.createElement("canvas").getContext("2d");
Object.assign(ruler, { font: `${style.fontWeight} ${style.fontSize} ${style.fontFamily}`, letterSpacing: style.letterSpacing, textAlign: "left", lang: document.documentElement.lang || "ja" });
title.querySelectorAll("span").forEach((line) => {
const { width } = ruler.measureText(line.textContent), ink = ruler.measureText(line.textContent.replace(/[、。,.」』)]+$/u, ""));
line.style.translate = `${(width / 2 - (ink.actualBoundingBoxRight - ink.actualBoundingBoxLeft) / 2) / parseFloat(style.fontSize)}em`;
});
});
// 動きを減らす設定では何もしない(タイルは最初から出ている)
if (reduceMotion) return;
// 隠すのは clip-path だけ。出る前のタイルの文も、読み上げには届く
const reveals = new Map([...section.querySelectorAll(".tile")].map((tile) => [tile, reveal(tile)]));
// 出すのは 1 回だけなので、スクロールを追い続ける ScrollTrigger は要らない。
// 下を 15% 縮めた画面にタイルが入ったら(上端が下から 15% の線を越えたら)知らせが来る。
// 同じときに越えたタイルは 1 回の知らせにまとまって届くので、その順番でずらして出す
const io = new IntersectionObserver(
(entries) => {
entries
.filter((entry) => entry.isIntersecting)
.forEach((entry, i) => {
io.unobserve(entry.target);
gsap.delayedCall(i * STAGGER, () => reveals.get(entry.target).play());
});
},
{ rootMargin: "0px 0px -15% 0px" }
);
reveals.forEach((_, tile) => io.observe(tile));
});