当サイトはパーティードレス・ステージ衣装の会社のコーポレートサイトです。扱っているものが「舞台に立つ人の衣装」なので、トップページを劇場に見立て、ファーストビューは閉じた緞帳(どんちょう)、スクロールすると幕がゆっくり上がって舞台の上の事業内容が現れる——という構成に作り替えました。本記事では、この開幕演出の実装方針と、途中でハマった position: sticky の仕様をまとめます。CSSの挙動については、MDNの公式ドキュメントを典拠として引用します。
実現したい演出
要件は次の3点です。
- ファーストビューは閉じた幕。スクロールに応じて幕だけが上がり、その間ページは進まない(幕の裏の舞台が、その場で現れてほしい)
- 幕が上がり切ったら、通常のスクロールに戻って次のセクションへ流れる
- スクロール操作そのものは奪わない。
preventDefault()でホイールを乗っ取る、いわゆるスクロールジャックはしない
構成 — sticky とスクロール代の「定石」
この種のスクロール演出(スクローリーテリング)には定石があります。演出対象を position: sticky で画面に固定し、その親に「演出に使うスクロール距離」ぶんの高さを持たせる方式です。ユーザーは普通にページをスクロールしているだけですが、親要素の中をスクロールしている間、演出対象は画面に張り付いたまま動かず、スクロール量だけが演出の進行度として消費されます。親を抜けると自然に固定が解けて、通常のスクロールに戻ります。
<div data-curtain-track>
<!-- 劇場(舞台+緞帳)。ヘッダーの直下に張り付く -->
<div class="sticky" style="top: var(--header-h)">
<section class="stage">
…舞台(事業内容のカード、床、照明)…
<div data-curtain>…緞帳(ワインレッドの幕)…</div>
</section>
</div>
<!-- 幕を開くためのスクロール代 -->
<div style="height: 190svh"></div>
</div>進行度は「トラックをどれだけスクロールで通過したか」から計算します。最初の30svh(画面高の3割)は幕が動かない「タメ」とし、そこから160svhかけて開き切る配分にしました。開幕のような一度きりの演出は、始まりが早すぎるより、少し引っ張るほうが舞台らしくなります。
// 0〜1の進行度。最初の30svhはタメ、そこから160svhかけて開く
const progress = () => {
const gone = headerH - track.getBoundingClientRect().top
return Math.max(0, Math.min(1, (gone - innerHeight * 0.3) / (innerHeight * 1.6)))
}幕の移動はスクロール位置に直結させず、毎フレーム目標値へ一定割合ずつ近づける補間(lerp)を挟んでいます。急なスクロールでも幕がふわっと遅れて追従し、重い布の慣性が出ます。
current += (target - current) * 0.16
curtain.style.transform = `translateY(${-current * (curtain.offsetHeight + hemH)}px)`また、途中まで自力で開けたユーザーは「続きが見たい」と判断できるので、進行度が1/3を超えたことを検知したら、残りは scrollTo({ behavior: 'smooth' }) で全開位置まで自動で運ぶようにしました。発動は進む方向のみで、幕を閉じ直せば再度有効になります。
ハマりどころ — padding では sticky が張り付かない
最初、スクロール代は親要素の padding-bottom: 190svh で確保していました。ところが、これでは sticky がまったく張り付きませんでした。position: sticky と top は計算値として正しく適用されているのに、要素はスクロールと一緒に流れていきます。
sticky が効かないときの定番の原因——祖先の overflow: hidden、祖先の transform、top の指定漏れ——をひとつずつ確認しましたが、いずれも該当しません。さらに切り分けると、同じ親の中でも、高さの小さいテスト要素は問題なく張り付くことが分かりました。張り付かないのは、親のコンテンツ全体を占める背の高い要素だけです。
原因は containing block(包含ブロック)の定義でした。MDNの position の解説では、sticky 要素は次のように説明されています。
It’s treated as relatively positioned until its containing block crosses a specified threshold (such as setting top to value other than auto) within its flow root (or the container it scrolls within), at which point it is treated as “stuck” until meeting the opposite edge of its containing block.
(relative 配置として扱われますが、フロールート(またはスクロールするコンテナ)の中で containing block が指定のしきい値——auto 以外の top の指定など——を越えると「固定」として扱われ、containing block の反対側の縁に達するまでその状態が続きます)
つまり sticky 要素が動ける範囲は containing block で決まります。そして containing block の定義は、MDNの解説にこうあります。
If the position property is static, relative, or sticky, the containing block is formed by the edge of the content box of the nearest ancestor element that is either a block container (…) or establishes a formatting context (…).
(position プロパティが static・relative・sticky の場合、containing block は、最も近いブロックコンテナ(またはフォーマッティングコンテキストを確立する)祖先要素の、コンテンツボックスの縁で形成されます)
鍵は「コンテンツボックスの縁」です。absolute の containing block が padding box で形成されるのとは異なり、sticky の可動域に親の padding は含まれません。今回の構成では、sticky 要素(劇場)自身が親のコンテンツのすべてで、コンテンツボックスの高さ=自分自身の高さ。可動域の余りはゼロなので、1pxも張り付けなかったわけです。高さの小さいテスト要素が張り付いたのは、コンテンツボックスの中に余りがあったからで、同じ理屈で説明がつきます。
解決は単純で、padding をやめて実体の要素でスクロール代を確保します。
<div data-curtain-track>
<div class="sticky" style="top: var(--header-h)">…劇場…</div>
<div style="height: 190svh"></div> <!-- padding ではなく実体のスペーサー -->
</div>スペーサーは親のコンテンツボックスに含まれるため、sticky 要素はその高さのぶんだけ張り付いていられます。
動きを減らしたい人・JSが無効な環境への配慮
大きな要素がスクロールに連動して動く演出は、前庭系の障害がある方には不調の引き金になり得ます。MDNの prefers-reduced-motion の解説にも、この設定が「非本質的な動きを最小限にしたい」というユーザーの意思表示であることが明記されています。今回は次のようにフォールバックを用意しました。
prefers-reduced-motion: reduceの環境では、幕の大きな移動をやめ、進行度に応じた透明度のフェードで開閉を表現する(オートスクロールも発動しない)- JSが無効な環境では
<noscript>のスタイルで幕とスペーサーを取り除き、最初から開いた舞台を通常のスクロールで見せる - 開き切って見えなくなった幕の上のボタン類は
inertを設定し、フォーカスやクリックの対象から外す
CSSだけで書ける日も近い
なお、スクロール連動アニメーションはCSSの scroll-driven animations(animation-timeline: scroll())として標準化が進んでおり、本来はこちらで書きたいところです。ただしMDNのリファレンスには、2026年7月時点で「Limited availability — This feature is not Baseline because it does not work in some of the most widely-used browsers」(限定的な提供状況——広く使われているブラウザの一部で動作しないため Baseline ではない)と明記されています。全ブラウザでの一貫した動作と、進行度に応じたJS側の制御(lerp・オートスクロール・inert の切り替え)が必要だったこともあり、今回は素の JavaScript(requestAnimationFrame と IntersectionObserver)で実装しました。
まとめ
- トップページに「sticky で劇場を固定し、スクロール代のぶんだけ緞帳が開く」演出を実装した。スクロール操作は奪わない
- sticky の可動域は containing block で決まり、containing block は親のコンテンツボックスの縁で形成される。padding はスクロール代にならない——スクロール代は実体の要素で確保する
- 進行度には「タメ」と lerp の慣性を入れ、1/3まで自力で開けたら残りを自動で運ぶ
prefers-reduced-motionではフェードに置き換え、JS無効時は最初から開いた状態にフォールバックする- CSSの scroll-driven animations は2026年7月時点で Baseline ではないため、今回は JavaScript で実装した
参考
- position - CSS | MDN — sticky の挙動と containing block の関係
- Layout and the containing block - CSS | MDN — position 値ごとの containing block の定義
- prefers-reduced-motion - CSS | MDN — 動きを減らす設定の検出と配慮の考え方
- scroll() - CSS | MDN — scroll-driven animations と提供状況
