デモ
デモを読み込めませんでした。
仕様
- 用途
- セクション
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
行の上の罫線と、行の中身を分けて動かします。行が画面に入ったら、罫線を scaleX で左から引き、少し遅れて中身を 80px 左から寄せながら浮かべます。同じころに画面へ入った行はひとまとめにして、上の行から 0.08 秒ずつずらすので、線と文字が上から順に左から流れ込んできます。
罫線は 1 秒の power3.inOut、中身は 0.1 秒遅れて 0.9 秒の power3.out で動かします。線を先に引き始めると、引かれた線の上に文字が乗ってくるように見えます。出し始めるのは、行の上端が画面の上から 85% の高さに届いたときです。画面の下端で始めると、いちばん動く所が画面の外で終わってしまいます。
1 行ずつ結ぶのではなく、ScrollTrigger.batch でまとめて扱うのが要点です。1 行ずつだと、速くスクロールしたときに何行もばらばらに動き出し、順に流れ込む感じが消えます。出すのは 1 回だけ(once)にして、上へ戻したときは何もしません。
中身は opacity だけで隠し、読み上げとキーボードの移動から外さないようにします。Tab で画面の下の行へ移ったときは、線を越えるのを待たずに、その行を出します。罫線は行の疑似要素に描き、引いた割合を CSS 変数で持たせているので、行ごとに線の要素を足さずに済みます。
つまみは、寄せる距離、ずらす間隔、線と中身の時間の差です。寄せる距離を大きくすると勢いが出ますが、読み始めるまでの間が延びます。動きを減らす設定では、最初から出ている状態で見せます。サービスの一覧、料金に含まれる項目、よくある質問のように、同じ形の行が縦に続く節に向いています。
コード
<section class="services" aria-labelledby="services-title">
<div class="services__intro">
<div>
<p class="services__label">Services</p>
<h2 class="services__title" id="services-title">サービス</h2>
</div>
<p class="services__lead">ここに説明文が入ります。</p>
</div>
<!-- 行はいくつ増やしても、つなぎ方は変わらない -->
<ol class="services__list">
<li class="service">
<a class="service__link" href="/services/brand-strategy/">
<span class="service__number" aria-hidden="true">01</span>
<span class="service__name">ブランド戦略</span>
<span class="service__detail">調査・ワークショップ</span>
<span class="service__arrow" aria-hidden="true">↗</span>
</a>
</li>
<li class="service">
<a class="service__link" href="/services/visual-identity/">
<span class="service__number" aria-hidden="true">02</span>
<span class="service__name">ビジュアル設計</span>
<span class="service__detail">ロゴ・書体・色</span>
<span class="service__arrow" aria-hidden="true">↗</span>
</a>
</li>
<li class="service">
<a class="service__link" href="/services/website-design/">
<span class="service__number" aria-hidden="true">03</span>
<span class="service__name">ウェブサイト制作</span>
<span class="service__detail">UX・UI・プロトタイプ</span>
<span class="service__arrow" aria-hidden="true">↗</span>
</a>
</li>
<li class="service">
<a class="service__link" href="/services/motion-systems/">
<span class="service__number" aria-hidden="true">04</span>
<span class="service__name">モーション設計</span>
<span class="service__detail">動きの原則・ライブラリ</span>
<span class="service__arrow" aria-hidden="true">↗</span>
</a>
</li>
<li class="service">
<a class="service__link" href="/services/product-interfaces/">
<span class="service__number" aria-hidden="true">05</span>
<span class="service__name">アプリの画面設計</span>
<span class="service__detail">デザインシステム・UI</span>
<span class="service__arrow" aria-hidden="true">↗</span>
</a>
</li>
<li class="service">
<a class="service__link" href="/services/content/">
<span class="service__number" aria-hidden="true">06</span>
<span class="service__name">コンテンツ制作</span>
<span class="service__detail">ことばのトーン・編集</span>
<span class="service__arrow" aria-hidden="true">↗</span>
</a>
</li>
<li class="service">
<a class="service__link" href="/services/launch-support/">
<span class="service__number" aria-hidden="true">07</span>
<span class="service__name">公開のサポート</span>
<span class="service__detail">キャンペーン・制作物</span>
<span class="service__arrow" aria-hidden="true">↗</span>
</a>
</li>
</ol>
</section>
.services {
--ink: #eeece6;
--mute: #a8a6a0;
--rule: rgba(238, 236, 230, 0.14); /* 行の上の罫線 */
padding: 96px clamp(24px, 7vw, 96px);
background-color: #111111;
color: var(--ink);
}
/* 見出しとリード文は、最後の行の文字の並びでそろえる */
.services__intro {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: last baseline;
gap: 24px 48px;
margin-bottom: 48px;
}
.services__label { margin: 0 0 16px; font-size: 13px; font-weight: 700; line-height: 1; letter-spacing: 0.14em; text-transform: uppercase; color: var(--mute); }
.services__title { margin: 0; font-size: clamp(44px, 6vw, 72px); font-weight: 800; line-height: 1.05; letter-spacing: -0.02em; }
.services__lead { margin: 0; font-size: 18px; line-height: 1.8; letter-spacing: 0.04em; word-break: auto-phrase; }
.services__list { margin: 0; padding: 0; list-style: none; }
/* 罫線は疑似要素に描き、--draw(0〜1)で左から引く。JS が動かないときは引いたまま */
.service { position: relative; }
.service::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background-color: var(--rule);
transform: scaleX(var(--draw, 1));
transform-origin: 0 50%;
}
.service__link {
display: grid;
grid-template-columns: 96px minmax(0, 1fr) auto auto;
align-items: center;
column-gap: 24px;
padding: 24px 0;
color: inherit;
text-decoration: none;
}
.service__link:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }
.service__number { font-size: 14px; letter-spacing: 0.04em; color: var(--mute); font-variant-numeric: tabular-nums; }
.service__name { font-size: clamp(24px, 3.4vw, 44px); font-weight: 700; line-height: 1.1; letter-spacing: -0.02em; }
.service__detail { font-size: 14px; letter-spacing: 0.04em; color: var(--mute); }
.service__arrow { font-size: 24px; line-height: 1; }
/* 狭い画面では、内容を名前の下へ回す */
@media (max-width: 720px) {
.service__link { grid-template-columns: 40px minmax(0, 1fr) auto; row-gap: 8px; }
.service__detail { grid-column: 2; grid-row: 2; }
.service__arrow { grid-column: 3; grid-row: 1; }
}
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger);
const rows = gsap.utils.toArray(".service");
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// 動きを減らす設定では何もしない(行は最初から出ている状態のまま)
if (rows.length > 0 && !reduceMotion) {
const shown = new Set();
const inner = (list) => list.map((row) => row.querySelector(".service__link"));
// まだ出していない行だけを、並びの順に 0.08 秒ずつずらして出す。線を先に引き始め、中身は 0.1 秒遅れて左から寄せる
const reveal = (batch) => {
const fresh = batch.filter((row) => !shown.has(row));
if (fresh.length === 0) return;
fresh.forEach((row) => shown.add(row));
gsap.to(fresh, { "--draw": 1, duration: 1, ease: "power3.inOut", stagger: 0.08 });
gsap.to(inner(fresh), { x: 0, opacity: 1, duration: 0.9, ease: "power3.out", stagger: 0.08, delay: 0.1 });
};
// 中身は opacity だけで隠す。visibility で隠すと、出る前の行が読み上げとタブ移動から外れる
gsap.set(rows, { "--draw": 0 });
gsap.set(inner(rows), { x: -80, opacity: 0 });
// 同じころに画面へ入った行をまとめて扱う。1 行ずつ結ぶと、速く送ったときにばらばらに動き出す
ScrollTrigger.batch(rows, { start: "top 85%", once: true, onEnter: reveal });
// Tab で画面の下の行へ移ったときは、線を越えるのを待たずに出す
document.addEventListener("focusin", (event) => {
const row = event.target.closest?.(".service");
if (rows.includes(row)) reveal([row]);
});
}