カードや実績を一気に出すと単調になりがちですが、1つずつ時間差で出すと、要素ごとに遅延を手書きする手間が増えます。この記事では、親要素に属性を付けるだけで子の遅延が自動で配られる「順次表示(stagger)」を、コピペで動くHTML・CSS・JSで紹介します。
- 親に属性を付けるだけで、子要素を順番に出現させる方法
- 子に遅延を手書きせず
index × stepで自動配分する仕組み - 順番に出る間隔(テンポ)と方向をグループ単位で切り替える設計
- 一度表示したら再生しない・動かない環境でもコンテンツが消えない組み立て方
このスニペットで作れるもの
グループ内の子要素が、画面に入ったタイミングで 0ms・step・step×2… と時間差で順番に出現するアニメーションです。子に遅延を書く必要はなく、親に属性を付けるだけで対象を増やせます。
動作の特徴は次の通りです。
- 親に属性を付けるだけで、配下の子が順番に出現する(子を増やすだけで連続感が出る)
- 順番に出る間隔(テンポ)と出現方向を、グループ単位でまとめて指定できる
- 一度表示したらそれ以降は再生しない(上下にスクロールしても出現は1回だけ)
- 動きを減らすOS設定が有効なときはアニメーションせず即時表示
- JSが動かない環境ではコンテンツが最初から表示される(消えない)
HTMLの構造を見てみよう
まずはHTMLを見てみましょう。親(グループ)に data-stagger を付け、出現させたい子に data-fade-in を付けるだけで動きます。
<div class="c-stagger">
<!-- グループ1: 既定ステップ(120ms)/ 上方向フェード -->
<h2 class="c-stagger__group-title">既定ステップで順番に出るカード一覧</h2>
<ul class="c-stagger__list" data-stagger>
<li class="c-stagger__item" data-fade-in>
<span class="c-stagger__num">1</span>
<p class="c-stagger__text">
親の <code>data-stagger</code> だけで、配下の子が順番に出現します。
この 1 番目は遅延なし(0ms)で表示されます。
</p>
</li>
<li class="c-stagger__item" data-fade-in>
<span class="c-stagger__num">2</span>
<p class="c-stagger__text">
2 番目は既定ステップ分(120ms)遅れて出現します。
子に遅延値を書かなくても、JS が連番で配ります。
</p>
</li>
<li class="c-stagger__item" data-fade-in>
<span class="c-stagger__num">3</span>
<p class="c-stagger__text">
3 番目はさらに遅れて(240ms)出現。要素を増やすだけで
連続感のある演出になります。
</p>
</li>
<li class="c-stagger__item" data-fade-in>
<span class="c-stagger__num">4</span>
<p class="c-stagger__text">
4 番目(360ms)。遅延の増分は <code>data-stagger-step</code> で
まとめて調整でき、子側の修正は不要です。
</p>
</li>
</ul>
<!-- グループ2: ステップ大きめ(250ms)/ 左方向スライドイン -->
<h2 class="c-stagger__group-title">ステップを広げて左から流すリスト</h2>
<ul
class="c-stagger__list"
data-stagger
data-stagger-step="250"
data-fade-direction="left"
>
<li class="c-stagger__item" data-fade-in>
<span class="c-stagger__num">1</span>
<p class="c-stagger__text">
<code>data-stagger-step="250"</code> で遅延差を広げると、
1 枚ずつ流れるような「間(ま)」のある演出になります。
</p>
</li>
<li class="c-stagger__item" data-fade-in>
<span class="c-stagger__num">2</span>
<p class="c-stagger__text">
方向は親の <code>data-fade-direction="left"</code> で
グループ全体にまとめて指定。左からのスライドインに切り替わります。
</p>
</li>
<li class="c-stagger__item" data-fade-in>
<span class="c-stagger__num">3</span>
<p class="c-stagger__text">
ステップと方向はグループ単位で変えられるため、同じ仕組みで
テンポも見た目も作り分けられます。
</p>
</li>
</ul>
<!-- グループ3: グリッド + 純フェード -->
<h2 class="c-stagger__group-title">グリッドで順次フェードするカード</h2>
<div
class="c-stagger__grid"
data-stagger
data-stagger-step="90"
data-fade-direction="none"
>
<article class="c-stagger__card" data-fade-in>
<h3 class="c-stagger__card-title">カード A</h3>
<p class="c-stagger__text">
<code>data-fade-direction="none"</code> は位置を動かさず
<code>opacity</code> だけで現れる純フェード。順次の「間」だけで魅せます。
</p>
</article>
<article class="c-stagger__card" data-fade-in>
<h3 class="c-stagger__card-title">カード B</h3>
<p class="c-stagger__text">
グリッドでも仕組みは同じ。DOM の並び順に沿って 0 / 90 / 180… ms と
遅延が増えていきます。
</p>
</article>
<article class="c-stagger__card" data-fade-in>
<h3 class="c-stagger__card-title">カード C</h3>
<p class="c-stagger__text">
ステップ 90ms と短めにすると、テンポよく畳みかけるように出現します。
</p>
</article>
<article class="c-stagger__card" data-fade-in>
<h3 class="c-stagger__card-title">カード D</h3>
<p class="c-stagger__text">
一度表示したら監視を解除するため、上下に何度スクロールしても
再アニメーションはしません(ワンショット表示)。
</p>
</article>
</div>
</div>構造の要点は次の3つです。
- 順番に出したいグループ(親)に
data-staggerを付ける - 出現させたい子に
data-fade-inを付ける(これが出現対象になる) - JSやCSSは触らず、属性だけで挙動が決まる
親と子の役割分担(data-stagger / data-fade-in)
親と子で役割が分かれています。親 data-stagger は「ここが順次表示グループだ」と示すフック、子 data-fade-in は「これが出現対象だ」と示すフックです。
ポイントは、遅延を子に書かないことです。子と子の遅延差は親側のステップ値から配られるため、子は「自分が出現対象である」と示すだけで済みます。
親の属性でステップ間隔と方向を決める
グループ単位の挙動は、親に付ける2つの属性で切り替えます。
data-stagger-step: 子と子の遅延差(ミリ秒)。省略時は120msdata-fade-direction: 出現方向(up/down/left/right/none)。省略時はup(下から上)
デモの3グループは、この組み合わせ違いです。1つ目は既定120msの上方向、2つ目は250msの左方向、3つ目は90msの純フェード(動きなし)です。同じ仕組みのまま、テンポと見た目を作り分けられます。
子の枚数が変わってもステップ値だけで調整でき、子側の修正は不要です。
遅延を自動配分する仕組み(本筋)
JS全体は次の通りです。グループが画面に入ったら、配下の子へ DOM順に遅延を連番で配るだけの構成です。
/**
* Intersection Observer による出現演出の応用。グループ単位で監視し、
* 配下の子要素を一定の遅延差で「順番に」出現させる(順次表示 / stagger)。
*
* 設計方針:
* - 監視対象は data-stagger 属性を持つ「親(グループ)」要素。グループが
* ビューポートに入った瞬間を 1 回検知し、配下の出現対象(data-fade-in)へ
* 遅延を連番で配って順番に表示する。
* - 遅延は各子に手書きせず、親の data-stagger-step(ms)から index × step で
* 自動計算する。子の枚数が変わっても、親のステップ値だけで調整できる。
* - 状態は .is-visible クラスを真実の源(single source of truth)とし、
* CSS が .is-visible セレクタで見た目(opacity / transform)を切り替える。
* 遅延は「いつ .is-visible を付けるか」のタイミング制御に閉じる。
* - ワンショット表示: グループは一度可視になったら unobserve して監視を解除する。
* これにより上下スクロールでの再アニメーションと多重発火を防ぐ。
*
* プログレッシブエンハンスメント / フォールバック:
* - 初期状態の「隠す」スタイルは CSS 側で html.js のときだけ適用される。
* IO 非対応・JS 無効環境では html が no-js のままになり、要素は最初から
* 表示される(コンテンツが消えない)。
* - IntersectionObserver 非対応ブラウザでは、全グループの全子要素を
* 即時に表示状態にする(順次の遅延は付けず一括表示)。
*/
(function () {
"use strict";
// 既定のステップ(子と子の間の遅延差・ms)。
// 順次表示として「順番に出ている」と認識でき、かつ待たされすぎない
// 値として 120ms を既定とする(親に data-stagger-step があればそれを優先)。
const DEFAULT_STEP = 120;
// 監視対象は「順次表示グループ」(親)
const groups = document.querySelectorAll("[data-stagger]");
if (groups.length === 0) return;
/**
* グループ配下の出現対象(直接の子に限る)を取得する。
* 入れ子のグループが将来できても、別グループの子まで巻き込まないよう
* 直接の子だけを対象にする。
* @param {HTMLElement} group - data-stagger を持つ親要素
* @returns {HTMLElement[]} 出現対象の子要素配列(DOM 順)
*/
function getStaggerTargets(group) {
const result = [];
for (let i = 0; i < group.children.length; i++) {
const child = group.children[i];
if (child.hasAttribute("data-fade-in")) {
result.push(child);
}
}
return result;
}
/**
* グループの子要素を、DOM 順に index × step の遅延で順次表示する。
* @param {HTMLElement} group - 対象グループ
*/
function revealGroup(group) {
// ステップ値(ms)。数値でない・負の場合は既定値にフォールバックする。
const parsed = Number(group.dataset.staggerStep);
const step = Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_STEP;
const targets = getStaggerTargets(group);
targets.forEach(function (el, index) {
const delay = index * step;
if (delay > 0) {
window.setTimeout(function () {
el.classList.add("is-visible");
}, delay);
} else {
el.classList.add("is-visible");
}
});
}
// IntersectionObserver 非対応環境のフォールバック:
// 監視せず、全グループの全子要素を即時に表示状態にする
// (順次の遅延は付けない / 隠れたまま残るのを防ぐ)。
if (!("IntersectionObserver" in window)) {
groups.forEach(function (group) {
getStaggerTargets(group).forEach(function (el) {
el.classList.add("is-visible");
});
});
return;
}
/**
* 観測オプション
* - threshold: 0.15
* グループの 15% がビューポートに入った時点で発火する。
* 0 だと画面下端にかすめた瞬間に出てしまい演出として認識されにくく、
* 高すぎると縦に長いグループが画面に収まらず発火しないため、
* 視認と確実な発火のバランスで 0.15 とする。
* - rootMargin: "0px 0px -10% 0px"
* ビューポート下端を 10% 内側に詰めて判定する。グループが画面の
* やや上に入ってから順次表示を始めることで、スクロールに対して
* 自然なタイミングになる。
*/
const observer = new IntersectionObserver(
function (entries, obs) {
entries.forEach(function (entry) {
if (!entry.isIntersecting) return;
revealGroup(entry.target);
// ワンショット表示: グループ単位で一度発火したら監視を解除する。
// 子の表示は setTimeout に予約済みのため、ここで解除しても順次表示は続く。
obs.unobserve(entry.target);
});
},
{
threshold: 0.15,
rootMargin: "0px 0px -10% 0px",
}
);
// 全グループを監視に登録
groups.forEach(function (group) {
observer.observe(group);
});
})();グループ単位で監視し、子へ連番で遅延を配る
監視対象は子ではなく、data-stagger を持つ親グループです。グループが画面に入った瞬間を検知すると、revealGroup が直接の子のうち data-fade-in を持つものを DOM順に取り出し、index × step で計算した遅延で .is-visible を付けていきます。
1番目は 0 × step = 0ms、2番目は 1 × step、3番目は 2 × step… と増えるので、子に遅延を書かなくても順番に出現します。状態は .is-visible に集約し、見た目の切り替えはCSSが担う、という役割分担です。
一度表示したら監視を解除する(ワンショット表示)
グループが一度可視になったら、unobserve で監視を解除します。子の表示は setTimeout に予約済みなので、監視を解除しても順次表示はそのまま続きます。
これにより、上下にスクロールして再交差しても多重発火しません。出現は1回だけです。
動かない環境・古いブラウザへの備え
JSが届かない・無効な環境では、子要素は最初から表示されます。要素を隠す初期状態のCSSが html.js のときだけ効くためで、JSが動かなければそのまま見える状態になります。
Intersection Observer 非対応のブラウザでは、コード冒頭で非対応を検知し、順次の遅延を付けず全子要素を即時表示します。どちらのケースでもコンテンツは消えません。
動きを減らす設定への配慮(prefers-reduced-motion)
@media (prefers-reduced-motion: reduce) を使い、OSの「視差効果を減らす」設定が有効なときは、フェードも順次の遅延も行わず、即時に最終状態(表示)で見せます。
この設定は、前庭機能障害などで画面の動きに敏感なユーザーが利用していることがあります。順番に出す演出を切っても情報が欠けないようにしておきます。
コピペで使うための完成コード
ここまでの内容をまとめて、HTML / CSS / JS をフルで再掲します。HTMLは <head> のインラインスクリプトを含む全体です。このまま貼り付ければ動作する状態です。
<!DOCTYPE html>
<html lang="ja" class="no-js">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>子要素を順番に表示する stagger</title>
<!--
JS が読み込まれる前に html の class を no-js → js へ書き換える。
head 内のインラインスクリプトで実行することで、画面描画前に
切り替わり、初期状態のチラつき(FOUC)を防ぐ。
JS 無効環境・このスクリプトが届かない環境では no-js のままになり、
CSS 側で要素を最初から表示状態にする(コンテンツ消失を防ぐ)。
-->
<script>
document.documentElement.classList.remove("no-js");
document.documentElement.classList.add("js");
</script>
<link rel="stylesheet" href="./assets/css/style.css" />
</head>
<body>
<main class="l-main">
<section class="p-section">
<div class="c-stagger">
<!-- グループ1: 既定ステップ(120ms)/ 上方向フェード -->
<h2 class="c-stagger__group-title">既定ステップで順番に出るカード一覧</h2>
<ul class="c-stagger__list" data-stagger>
<li class="c-stagger__item" data-fade-in>
<span class="c-stagger__num">1</span>
<p class="c-stagger__text">
親の <code>data-stagger</code> だけで、配下の子が順番に出現します。
この 1 番目は遅延なし(0ms)で表示されます。
</p>
</li>
<li class="c-stagger__item" data-fade-in>
<span class="c-stagger__num">2</span>
<p class="c-stagger__text">
2 番目は既定ステップ分(120ms)遅れて出現します。
子に遅延値を書かなくても、JS が連番で配ります。
</p>
</li>
<li class="c-stagger__item" data-fade-in>
<span class="c-stagger__num">3</span>
<p class="c-stagger__text">
3 番目はさらに遅れて(240ms)出現。要素を増やすだけで
連続感のある演出になります。
</p>
</li>
<li class="c-stagger__item" data-fade-in>
<span class="c-stagger__num">4</span>
<p class="c-stagger__text">
4 番目(360ms)。遅延の増分は <code>data-stagger-step</code> で
まとめて調整でき、子側の修正は不要です。
</p>
</li>
</ul>
<!-- グループ2: ステップ大きめ(250ms)/ 左方向スライドイン -->
<h2 class="c-stagger__group-title">ステップを広げて左から流すリスト</h2>
<ul
class="c-stagger__list"
data-stagger
data-stagger-step="250"
data-fade-direction="left"
>
<li class="c-stagger__item" data-fade-in>
<span class="c-stagger__num">1</span>
<p class="c-stagger__text">
<code>data-stagger-step="250"</code> で遅延差を広げると、
1 枚ずつ流れるような「間(ま)」のある演出になります。
</p>
</li>
<li class="c-stagger__item" data-fade-in>
<span class="c-stagger__num">2</span>
<p class="c-stagger__text">
方向は親の <code>data-fade-direction="left"</code> で
グループ全体にまとめて指定。左からのスライドインに切り替わります。
</p>
</li>
<li class="c-stagger__item" data-fade-in>
<span class="c-stagger__num">3</span>
<p class="c-stagger__text">
ステップと方向はグループ単位で変えられるため、同じ仕組みで
テンポも見た目も作り分けられます。
</p>
</li>
</ul>
<!-- グループ3: グリッド + 純フェード -->
<h2 class="c-stagger__group-title">グリッドで順次フェードするカード</h2>
<div
class="c-stagger__grid"
data-stagger
data-stagger-step="90"
data-fade-direction="none"
>
<article class="c-stagger__card" data-fade-in>
<h3 class="c-stagger__card-title">カード A</h3>
<p class="c-stagger__text">
<code>data-fade-direction="none"</code> は位置を動かさず
<code>opacity</code> だけで現れる純フェード。順次の「間」だけで魅せます。
</p>
</article>
<article class="c-stagger__card" data-fade-in>
<h3 class="c-stagger__card-title">カード B</h3>
<p class="c-stagger__text">
グリッドでも仕組みは同じ。DOM の並び順に沿って 0 / 90 / 180… ms と
遅延が増えていきます。
</p>
</article>
<article class="c-stagger__card" data-fade-in>
<h3 class="c-stagger__card-title">カード C</h3>
<p class="c-stagger__text">
ステップ 90ms と短めにすると、テンポよく畳みかけるように出現します。
</p>
</article>
<article class="c-stagger__card" data-fade-in>
<h3 class="c-stagger__card-title">カード D</h3>
<p class="c-stagger__text">
一度表示したら監視を解除するため、上下に何度スクロールしても
再アニメーションはしません(ワンショット表示)。
</p>
</article>
</div>
</div>
</section>
</main>
<script src="./assets/js/script.js" defer></script>
</body>
</html>/* ================================================
カスタムプロパティ
出現演出系で共通のトークン名
(--fade-distance / --fade-duration / --fade-easing)
を踏襲し、距離・時間・イージングを一元管理する。
順次表示の「遅延差」は HTML 側の属性で渡すため、
ここでは演出そのものの値だけを持つ。
================================================ */
:root {
--fade-color-text: #2b2b2b;
--fade-color-text-muted: #555;
--fade-color-border: #e0e0e0;
--fade-color-bg: #ffffff;
--fade-color-accent: #1fbfa8;
--fade-radius: 8px;
--fade-card-padding-y: 20px;
--fade-card-padding-x: 20px;
/* 出現演出のトークン
--fade-distance: 初期位置のずらし量(スライド距離)
--fade-duration: フェード/スライドの所要時間
--fade-easing: 減速して止まる自然なイージング */
--fade-distance: 24px;
--fade-duration: 0.6s;
--fade-easing: cubic-bezier(0.16, 1, 0.3, 1);
}
/* ================================================
ベースリセット(デモページ用)
================================================ */
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue",
"Hiragino Sans", "Noto Sans JP", sans-serif;
color: var(--fade-color-text);
background-color: #fafafa;
line-height: 1.7;
}
code {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 0.92em;
padding: 0.1em 0.35em;
background-color: #eef0f3;
border-radius: 4px;
}
/* ================================================
l-main / p-section: デモページのレイアウト
================================================ */
.l-main {
max-width: 720px;
margin-inline: auto;
padding: 40px 20px;
}
.p-section__title {
font-size: 22px;
font-weight: 700;
margin: 0 0 16px;
}
.p-section__text {
margin: 0 0 24px;
color: var(--fade-color-text-muted);
}
@media (min-width: 768px) {
.p-section__title {
font-size: 28px;
}
}
/* ================================================
c-stagger: 順次表示するグループを縦に並べる
ビューポートより縦に長くなる余白を確保し、
各グループの出現タイミングを確認できるようにする。
================================================ */
.c-stagger {
display: flex;
flex-direction: column;
gap: 16px;
/* スクロールで出現する様子を確認できるよう、
ビューポートより縦に長くなる余白を確保する。 */
padding-bottom: 60vh;
}
.c-stagger__group-title {
margin: 32px 0 4px;
font-size: 16px;
font-weight: 700;
color: var(--fade-color-text);
}
.c-stagger__group-title:first-child {
margin-top: 0;
}
/* リスト型グループ(縦積み) */
.c-stagger__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 16px;
}
/* グリッド型グループ(モバイル1列 → 768px以上で2列) */
.c-stagger__grid {
display: grid;
grid-template-columns: 1fr;
gap: 16px;
}
@media (min-width: 768px) {
.c-stagger__grid {
grid-template-columns: repeat(2, 1fr);
}
}
/* ================================================
出現演出の対象(カード・リスト項目)
================================================ */
.c-stagger__item,
.c-stagger__card {
padding: var(--fade-card-padding-y) var(--fade-card-padding-x);
background-color: var(--fade-color-bg);
border: 1px solid var(--fade-color-border);
border-left: 4px solid var(--fade-color-accent);
border-radius: var(--fade-radius);
}
.c-stagger__item {
display: flex;
align-items: flex-start;
gap: 12px;
}
.c-stagger__num {
flex: 0 0 auto;
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 700;
color: var(--fade-color-bg);
background-color: var(--fade-color-accent);
border-radius: 50%;
}
.c-stagger__card-title {
margin: 0 0 8px;
font-size: 16px;
font-weight: 700;
}
.c-stagger__text {
margin: 0;
color: var(--fade-color-text-muted);
}
/* ================================================
出現演出の本体
プログレッシブエンハンスメント:
- JS が動く環境では html.js が付与される。
このときだけ初期状態を「透明+ずらした位置」にし、
.is-visible が付くまで隠す。
- JS 無効・このスクリプトが届かない環境では html.no-js の
ままになり、下記ルールが効かないため、要素は最初から
通常表示される(コンテンツが消えない)。
出現対象は子の data-fade-in 属性で識別する。順次表示の
遅延は JS が各要素に直接付与するため CSS には現れない。
================================================ */
.js [data-fade-in] {
opacity: 0;
transform: translateY(var(--fade-distance));
transition:
opacity var(--fade-duration) var(--fade-easing),
transform var(--fade-duration) var(--fade-easing);
/* 不可視の間はクリック/フォーカス対象にしない(透明な要素の誤操作を防ぐ)。
レイアウトは確保したまま、表示後に pointer-events を auto へ戻す。 */
pointer-events: none;
will-change: opacity, transform;
}
/* 出現方向の切り替え(初期位置のずらし方向だけを変える)
親グループ・子要素のどちらに付いても効くよう、属性セレクタで分岐する。
親に付けた場合は子へ継承的に適用したいので、子孫セレクタ側を用意する。
要素「自身」に方向を付けるパターンは、出現対象(data-fade-in)であるとき
だけ効かせる([data-fade-in][data-fade-direction])。素の属性セレクタだと、
方向を「親コンテナ」に付けた場合にその親自身までマッチしてしまう。親は
出現対象ではない(is-visible が付かない)ため translate が戻されず、
コンテナごと固定でずれる。data-fade-in でガードすることでこれを防ぐ。 */
.js [data-fade-in][data-fade-direction="down"],
.js [data-fade-direction="down"] [data-fade-in] {
transform: translateY(calc(var(--fade-distance) * -1));
}
.js [data-fade-in][data-fade-direction="left"],
.js [data-fade-direction="left"] [data-fade-in] {
transform: translateX(calc(var(--fade-distance) * -1));
}
.js [data-fade-in][data-fade-direction="right"],
.js [data-fade-direction="right"] [data-fade-in] {
transform: translateX(var(--fade-distance));
}
/* 純フェード(動きなし): transform を一切当てず、opacity だけで出現させる。
初期状態は opacity: 0 のみ。translate を打ち消すのではなく最初から付与
しないため、transition も opacity だけにして無駄な合成を避ける。
親に付けた場合も配下の出現対象すべてに効くようにする。
方向系と同様、要素自身に付くパターンは出現対象のときだけに絞り、
非対象の親コンテナに余計な transition が当たらないようにする。 */
.js [data-fade-in][data-fade-direction="none"],
.js [data-fade-direction="none"] [data-fade-in] {
transform: none;
transition: opacity var(--fade-duration) var(--fade-easing);
will-change: opacity;
}
/* ビューポート進入後に付与される最終状態
どの方向指定でも translate を 0 に戻し、本来の位置へ。
will-change はワンショット表示の都合上、出現後はもう不要になる。
CSS 側の最終状態で auto に戻すことで、表示後も合成レイヤーが残り
続けるのを防ぐ(要素数が多いページでのメモリ常駐を回避)。
状態管理を JS に分散させず .is-visible に集約しているため、
順次表示でも見た目の切り替えはこの 1 ルールに集約される。
セレクタは [data-fade-in].is-visible で要素フックを残し、詳細度を
(0,3,0) に保つ。方向の子孫セレクタ(.js [dir] [data-fade-in] = 0,3,0)
と同点になり、ソース順で後方の本ルールが勝つため、left/right/down では
translate(0,0) が、none では will-change:auto が確実に最終勝ちする。
.is-visible は出現対象(data-fade-in を持つ子)にのみ付与されるため、
この要素フックは常に一致する。 */
.js [data-fade-in].is-visible {
opacity: 1;
transform: translate(0, 0);
/* 表示されたら操作可能に戻す。 */
pointer-events: auto;
will-change: auto;
}
/* ================================================
prefers-reduced-motion: 動きを減らす設定への配慮
前庭機能障害等で OS の「視差効果を減らす」を有効に
しているユーザーには、フェード/スライドも順次の遅延も
行わず、即時に最終状態(表示)で見せる。
================================================ */
@media (prefers-reduced-motion: reduce) {
.js [data-fade-in] {
opacity: 1;
transform: none;
transition: none;
/* 即時表示されるため、表示と同時に操作可能にする
(可視なのに操作不可になる不整合を防ぐ)。 */
pointer-events: auto;
will-change: auto;
}
}/**
* Intersection Observer による出現演出の応用。グループ単位で監視し、
* 配下の子要素を一定の遅延差で「順番に」出現させる(順次表示 / stagger)。
*
* 設計方針:
* - 監視対象は data-stagger 属性を持つ「親(グループ)」要素。グループが
* ビューポートに入った瞬間を 1 回検知し、配下の出現対象(data-fade-in)へ
* 遅延を連番で配って順番に表示する。
* - 遅延は各子に手書きせず、親の data-stagger-step(ms)から index × step で
* 自動計算する。子の枚数が変わっても、親のステップ値だけで調整できる。
* - 状態は .is-visible クラスを真実の源(single source of truth)とし、
* CSS が .is-visible セレクタで見た目(opacity / transform)を切り替える。
* 遅延は「いつ .is-visible を付けるか」のタイミング制御に閉じる。
* - ワンショット表示: グループは一度可視になったら unobserve して監視を解除する。
* これにより上下スクロールでの再アニメーションと多重発火を防ぐ。
*
* プログレッシブエンハンスメント / フォールバック:
* - 初期状態の「隠す」スタイルは CSS 側で html.js のときだけ適用される。
* IO 非対応・JS 無効環境では html が no-js のままになり、要素は最初から
* 表示される(コンテンツが消えない)。
* - IntersectionObserver 非対応ブラウザでは、全グループの全子要素を
* 即時に表示状態にする(順次の遅延は付けず一括表示)。
*/
(function () {
"use strict";
// 既定のステップ(子と子の間の遅延差・ms)。
// 順次表示として「順番に出ている」と認識でき、かつ待たされすぎない
// 値として 120ms を既定とする(親に data-stagger-step があればそれを優先)。
const DEFAULT_STEP = 120;
// 監視対象は「順次表示グループ」(親)
const groups = document.querySelectorAll("[data-stagger]");
if (groups.length === 0) return;
/**
* グループ配下の出現対象(直接の子に限る)を取得する。
* 入れ子のグループが将来できても、別グループの子まで巻き込まないよう
* 直接の子だけを対象にする。
* @param {HTMLElement} group - data-stagger を持つ親要素
* @returns {HTMLElement[]} 出現対象の子要素配列(DOM 順)
*/
function getStaggerTargets(group) {
const result = [];
for (let i = 0; i < group.children.length; i++) {
const child = group.children[i];
if (child.hasAttribute("data-fade-in")) {
result.push(child);
}
}
return result;
}
/**
* グループの子要素を、DOM 順に index × step の遅延で順次表示する。
* @param {HTMLElement} group - 対象グループ
*/
function revealGroup(group) {
// ステップ値(ms)。数値でない・負の場合は既定値にフォールバックする。
const parsed = Number(group.dataset.staggerStep);
const step = Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_STEP;
const targets = getStaggerTargets(group);
targets.forEach(function (el, index) {
const delay = index * step;
if (delay > 0) {
window.setTimeout(function () {
el.classList.add("is-visible");
}, delay);
} else {
el.classList.add("is-visible");
}
});
}
// IntersectionObserver 非対応環境のフォールバック:
// 監視せず、全グループの全子要素を即時に表示状態にする
// (順次の遅延は付けない / 隠れたまま残るのを防ぐ)。
if (!("IntersectionObserver" in window)) {
groups.forEach(function (group) {
getStaggerTargets(group).forEach(function (el) {
el.classList.add("is-visible");
});
});
return;
}
/**
* 観測オプション
* - threshold: 0.15
* グループの 15% がビューポートに入った時点で発火する。
* 0 だと画面下端にかすめた瞬間に出てしまい演出として認識されにくく、
* 高すぎると縦に長いグループが画面に収まらず発火しないため、
* 視認と確実な発火のバランスで 0.15 とする。
* - rootMargin: "0px 0px -10% 0px"
* ビューポート下端を 10% 内側に詰めて判定する。グループが画面の
* やや上に入ってから順次表示を始めることで、スクロールに対して
* 自然なタイミングになる。
*/
const observer = new IntersectionObserver(
function (entries, obs) {
entries.forEach(function (entry) {
if (!entry.isIntersecting) return;
revealGroup(entry.target);
// ワンショット表示: グループ単位で一度発火したら監視を解除する。
// 子の表示は setTimeout に予約済みのため、ここで解除しても順次表示は続く。
obs.unobserve(entry.target);
});
},
{
threshold: 0.15,
rootMargin: "0px 0px -10% 0px",
}
);
// 全グループを監視に登録
groups.forEach(function (group) {
observer.observe(group);
});
})();カスタマイズしたい場面でよく触るのは次のポイントです。
- 順番に出る間隔(テンポ): 親の
data-stagger-step属性(ミリ秒) - 出現方向: 親の
data-fade-direction属性(up/down/left/right/none) - 出現の距離・速さ・動き方:
--fade-distance/--fade-duration/--fade-easing - 発火の早さ: JSの
threshold/rootMargin - 既定のステップ間隔: JSの
DEFAULT_STEP(既定120ms)
よくある質問
- 子要素ごとに遅延(delay)を手書きする必要はありますか?
ありません。親要素に
data-staggerを付ければ、配下の子へ JS が DOM順に「0ms・ステップ・ステップ×2…」と遅延を連番で配ります。子の枚数が変わっても、親のdata-stagger-stepを変えるだけで調整できます。
- 順番に出る間隔(テンポ)を変えたいです。
親要素の
data-stagger-stepに遅延差(ミリ秒)を指定します。省略時は120msです。値を小さくするとテンポよく畳みかけるように、大きくすると1つずつ流れるような「間」のある出現になります。
- 出現する方向はグループごとに変えられますか?
変えられます。親要素に
data-fade-direction(上から・下から・左から・右から・動きなし)を付けると、配下の子要素すべてにまとめて効きます。グループ単位でステップ間隔と方向を組み合わせて、テンポも見た目も作り分けられます。
まとめ
グループ内の子要素を時間差で順次表示する stagger を紹介しました。要点は次の通りです。
- 親の属性だけで遅延を自動配分する(子への手書き管理が不要)
- 順番に出る間隔(テンポ)と方向は、グループ単位でまとめて切り替えられる
- 状態は
.is-visibleに集約し、JSとCSSの役割を分ける - 動かない環境・ワンショット表示・動きを減らす設定への配慮を最初から組み込む
まずはコピペして動かし、data-stagger-step の値を変えてテンポの違いを体感してみてください。
【関連記事】
