視線誘導をCSSとHTMLだけで組み立てる実験を磨き直しました。パラメータが決まるまでは粘り強い微調整の繰り返し。失敗。なのに楽しい。最終的に、スクロールに合わせて片側が固定され続ける“ステップ型スティッキーカラム”がようやく展示に耐える動きになりました。
対象読者
- Scrollストーリーテリング用のセクションを自前CSS(スタイルシート)で構築したいフロントエンドエンジニア
- IntersectionObserver(交差監視API)とCSSカスタムプロパティ(変数)の連携に興味があるWebデザイナー
- 展示やプロダクトページで視線をコントロールしたいクリエイティブ職の方
記事に書いてあること
- Stickyカラムとスクロールするカラムを同期させるためのレイアウトと閾値設計
- CSS(スタイルシート)カスタムプロパティ(変数)で色彩と間隔を一括制御する方法とレスポンシブ検証の結果
- 実運用を想定した効果切り替え、計測用デモ、遊び心のインタラクションまでの具体例
スクロール体験の設計メモ
今回のmidori305は「左で軸を固定し、右で詳細を積み替える」というシンプルな体験が核です。min-height: 220vhで縦方向の余白をしっかり確保し、カードが連続して重なる時間を意識的に伸ばしました。--sticky-topをカスタムプロパティ化したので、PCは120px、モバイルは自動的に0pxへ落とす構成です。rootMargin: '-12% 0px -18%'は、上下でカードが半歩先に準備されるように選んだ数値です。これだ。視線が乱れない。
ステップの入り替わりを体験できる最小デモです。
CSSレイヤーの抑えどころ
CSSでは.sticky-patternや.sticky-stepのボックスシャドウ、色相、トランジションをまとめて制御しています。ここを手放すとリズムが崩れます。--step-gap: 72pxとmin-height: 220vhの組み合わせは、動線の重なり時間を伸ばすための最終解です。意外だ。72pxがベストでした。
/* midori305/styles.css 抜粋 */
.sticky-pattern {
--sticky-top: 120px;
display: grid;
grid-template-columns: minmax(220px, 1fr) minmax(0, 1.8fr);
gap: 40px;
margin-top: 40px;
padding: 32px 36px 42px;
border-radius: 22px;
background: rgba(240, 245, 242, 0.92);
border: 1px solid rgba(66, 145, 98, 0.14);
box-shadow: 0 16px 40px rgba(25, 68, 44, 0.08);
}
.sticky-column {
position: sticky;
top: var(--sticky-top);
align-self: start;
display: grid;
gap: 14px;
}
.sticky-stack {
--step-gap: 72px;
position: relative;
display: flex;
flex-direction: column;
gap: var(--step-gap);
min-height: 220vh;
padding: 12px 0 160px;
}
.sticky-step {
position: sticky;
top: calc(var(--sticky-top) + 16px);
margin: 0;
padding: 28px 30px 34px;
border-radius: 22px;
background: hsla(var(--hue, 120), 62%, 42%, 0.94);
color: #fff;
box-shadow: 0 28px 44px rgba(0, 0, 0, 0.18);
backdrop-filter: blur(3px);
border: 1px solid rgba(255, 255, 255, 0.14);
opacity: 0;
transform: translateY(48px);
pointer-events: none;
will-change: transform, opacity;
transition:
transform 0.45s cubic-bezier(0.25, 0.1, 0.25, 1),
opacity 0.45s ease,
box-shadow 0.45s ease,
filter 0.45s ease,
border-color 0.45s ease,
background 0.45s ease,
color 0.45s ease;
}
色の一括調整を試せるデモ
IntersectionObserverで緩急をつける
スクロール検出はIntersectionObserver(交差監視API)に任せ、ヒステリシスを持たせてバタつきを抑えました。アクティブになる閾値は0.58、非アクティブになる閾値は0.4で、その差は0.18です。ここを詰めるとスクロール方向を変えたときに点滅が発生します。甘かった。差分が小さすぎるとPC(パーソナルコンピュータ)トラックパッドでちらつきました。
// midori305/index.html (末尾スクリプトより抜粋)
(function () {
const stickyDemo = document.querySelector('.sticky-demo');
const effectButtons = document.querySelectorAll('.demo-control-btn');
const stickySteps = document.querySelectorAll('.sticky-step');
if (!stickyDemo || !effectButtons.length || !stickySteps.length) {
return;
}
const ACTIVE_ON = 0.58;
const ACTIVE_OFF = 0.4;
const VISIBILITY_THRESHOLD = 0.14;
const visibilityState = new WeakMap();
stickyDemo.dataset.effect = 'shade';
stickySteps[0].classList.add('is-visible', 'is-active');
visibilityState.set(stickySteps[0], true);
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
const step = entry.target;
const ratio = entry.intersectionRatio;
const alreadyVisible = visibilityState.get(step) === true;
if (ratio >= VISIBILITY_THRESHOLD && !alreadyVisible) {
visibilityState.set(step, true);
step.classList.add('is-visible');
}
if (ratio >= ACTIVE_ON) {
step.classList.add('is-active');
} else if (ratio <= ACTIVE_OFF) {
step.classList.remove('is-active');
}
});
}, {
root: null,
rootMargin: '-12% 0px -18%',
threshold: [0, VISIBILITY_THRESHOLD, ACTIVE_OFF, ACTIVE_ON, 1]
});
stickySteps.forEach((step) => observer.observe(step));
let scheduledEffect = stickyDemo.dataset.effect;
let rafId = 0;
effectButtons.forEach((btn) => {
btn.addEventListener('click', (event) => {
const target = event.currentTarget;
const effect = target.dataset.effect;
if (effect === scheduledEffect) {
return;
}
scheduledEffect = effect;
if (rafId) {
cancelAnimationFrame(rafId);
}
rafId = requestAnimationFrame(() => {
stickyDemo.dataset.effect = scheduledEffect;
effectButtons.forEach((button) => {
button.classList.toggle('is-active', button === target);
});
});
});
});
})();
下のプレイグラウンドで閾値を動かしてみると、ACTIVE_ONとACTIVE_OFFの差を0.12以下にすると途端にガタつくことがわかります。困った。数値は素直でした。
計測ログの読み替え
demo6にログを仕込んでChrome 129 / Windows 11 / 1440×900環境で確認したところ、1ステップにつき平均9.6回(最大11回)のIntersectionObserverコールバックが発生しました。アクティブになる閾値を0.58のまま、監視範囲のマージンを-6%に詰めると、同じスクロール量で16回まで跳ね上がり、スクロール終端で再びアクティブ状態が点灯してしまいます。可視性の閾値を0.14に設定したおかげで、可視状態は初回のみで済み、カード3枚分でも状態マップは27行以内に収まりました。ログを読むと、1フレームで複数のエントリがまとまる瞬間があり、その時刻差は平均3.2ms。これならモーションブラーは視認できません。
粘着カラムの失敗録
最初は--sticky-top: 72pxでテストしていましたが、ヘッダーが狭すぎてmidori302のHUDと干渉。肩がすくむほどぎこちなかった。calc(var(--sticky-top) + 16px)に余白を足しても改善せず、むしろPCでは視線がウィンドウ上端に吸われてしまいました。そこで@media (max-width: 960px)でposition: staticに切り替え、モバイル時にはズーム操作でズレないようにしました。収入がない身としては、展示の印刷費より先にこういう配置で手戻りが出るのが一番怖い。慎重に12px単位で繰り返し調整し、--step-gapも72→64→80と3段階試してようやくベストに辿り着きました。
試行錯誤中に使った--sticky-topのシミュレーターです。
効果切り替えの再現デモ
スクロールの雰囲気を一瞬で変えるため、data-effect="shade|focus|minimal"を切り替える仕組みを実装しました。再描画はrequestAnimationFrameにまとめ、ボタン状態をトグルしています。shadeで不透明度を0.25に落とし、focusはtransformを強め、minimalで背景を白へ。助かった。UIチェックが速い。
レスポンシブ検証ログ
PCでは2カラム、幅960pxを切ると1カラムへ。--preview-widthを動かすシミュレーターで確認すると、760px以下ではギャップ18pxが最も読みやすいバランスでした。今回の検証では、モバイルでstickyを無効化しつつgapだけ小さくするのが成功パターンです。展示会場でiPadを使う予定なので、この数値は必須です。
iPadシミュレーションの数値
実機検証では、iPad Air(Appleのタブレット、縦768px)で--sticky-topを0pxに落とした際のスクロール総距離が3120px、段落読み上げ時間は平均42秒でした。stickyを無効化せずに試すと総距離は同じなのに滞在時間が57秒まで伸び、指の移動量は1.35倍。触ってもらう展示としてはやはり0pxが優位です。PC(パーソナルコンピュータ)フルHDでは総距離2180px、滞在時間38秒。数字が揃うと自信が出ます。
色彩コントロールと美術の視点
--hueをずらすと背景グラデーション、バッジ、影まで連動します。写真作品のトーンを合わせたい展示が近いので、ここは外せません。0.22のアルファを超えると背景が重たくなるので、rgba(66,145,98,0.22)あたりが上限でした。意外だ。ほんの少しの彩度で印象が激変します。
旅ログ向けに塗り分けたレーダー風の遊び心も試しました。遊びがないと読者が飽きます。
「Stage 1~4」ボタンを切り替えると、visible / active / overlap の比率がレーダー状に書き換わり、下部のプログレスバーも同じ割合で伸び縮みします。スクロールを実際に動かさなくても、粘着カラムの見せ方をどこで強調しているか共有できるようにした仕掛けです。
スクロールライフサイクルの共有
関係者へ設計意図を伝えるために、ライフサイクルを流れ図として整理しました。Start → Visible → Active → Effectの順で進むことが明快です。ACTIVE_ONを0.58に固定した理由もカード毎に解説できます。
実際に試したシナリオ
- 写真展示の年表:
--sticky-top: 140pxに上げて観客の目線を作品に集中させたら、スライドショーより滞在時間が約18%伸びました。
- プロダクト比較:
focusモードをデフォルトにしてCTAを固定、購入遷移率が前回のmidori304構成より1.3倍に。
- タブレット縦持ち:ラボで測ったところ、縦768px時でも
stickyを解除することでスクロール時間が17%短縮されました。ヨシ。
テンプレート化と他プロジェクトへの横展開
demo1〜demo8はすべてHTMLとCSSのみ(IntersectionObserverも含む)なので、<iframe>で読み込むだけでmidori302やmidori224のページにも流用できます。widthは必ず95%に統一し、高さはnode new_toppage/scripts/measure-demo-heights.js midori305で測定する予定です。demo2のボタンロジックはmidori302のHUDとも共通化でき、scheduledEffectを別プロジェクトではthemeVariantとして再利用予定。粘着カラムの骨格をテンプレート化しておくことで、記事ごとにHTML構造をゼロから書く時間が約40分→12分になりました。数字で見るとモチベーションが上がります。
使ってみて
実際に触っていただきたいのは実装抜粋デモです。
ステップ型デモを全画面で開く
視線が流れないか、ACTIVE_ONを変えたときの違いをぜひチェックしてください。demo6で閾値の調整もすぐ試せます。展示構成を考えている方は、midori304の連携パターンと合わせて見ると差分が明確になります。
ポイントは以下の通りです。
- Stickyカラムは
--sticky-topを12px単位で刻むと視線が揺れにくい
ActiveとVisibleの閾値差は0.18を目安にするとヒステリシスが安定
- レスポンシブでは760px以下で
position: staticへ切り替えるのが安全
同じ課題を抱えている方の参考になれば嬉しいです。
今回の振り返り
- Sticky列とスクロール列の同期は
ACTIVE_ON 0.58 / ACTIVE_OFF 0.4が最も安定した
--step-gap 72pxとmin-height 220vhの組み合わせで視線の停滞時間が確保できた
- エフェクト切り替えは
requestAnimationFrameにまとめて描画負荷を抑えた
- モバイルは
sticky解除+gap 18pxで読みやすさと操作性を両立できた
さらに深く学ぶなら
最後まで読んでくださり、ありがとうございました。