Hugo Stackテーマのモバイルヘッダーを改善する

スクロール追従ヘッダーのバグ修正からアニメーション強化、iOS safe area対応まで

このブログは Hugo の Stack テーマを使っている。Stack テーマは GPLv3 ライセンスで配布されており、カスタマイズや改変は明確に許可されている(フッターの「Theme Stack designed by Jimmy」のアトリビューション表示を維持する必要がある)。モバイル表示では、スクロールするとサイト名を含むヘッダーが画面上部に固定される仕組みをカスタムで実装している。

テーマ本体のファイルを直接編集するとバージョンアップ時に上書きされてしまうため、Hugo のオーバーライド機構を使い、assets/js/mobile-header.jsassets/scss/custom.scss の2ファイルだけで全ての変更を完結させている。

この記事では、このモバイルヘッダーに対して行った4段階の改善を解説する。

1. バグ修正: ヘッダーが突然消える問題

Stack テーマのモバイル表示には、もともとスクロール追従するヘッダーの仕組みがない。そこでカスタム CSS と JS を追加し、一定量スクロールするとサイト名とハンバーガーメニューが画面上部に固定表示されるようにしていた。

症状

しばらくスクロール操作を繰り返していると、この固定ヘッダーが突然消えて戻ってこなくなることがあった。特にモバイルブラウザで再現しやすい。

原因

根本原因は updateThreshold() 関数にあった。この関数はヘッダーを固定するスクロール位置(しきい値)を計算するもので、getBoundingClientRect() を使っている。

1
2
3
function updateThreshold() {
    headerThreshold = siteMeta.getBoundingClientRect().top + window.scrollY;
}

問題は、position: fixed が適用された状態でこの関数が呼ばれると、getBoundingClientRect().top がビューポート上端からの距離(≈ 0)を返してしまうこと。その結果、しきい値が scrollY 付近に書き換わり、少しスクロールバックしただけで固定が解除される。

そしてこの関数が fixed 中に呼ばれるトリガーは、モバイルブラウザのアドレスバーの伸縮だ。スクロールに連動してアドレスバーが表示/非表示になると、ブラウザは resize イベントを発火する。resize ハンドラ内で updateThreshold() が呼ばれ、しきい値が壊れる。

修正

fixed 状態のときは updateThreshold() をスキップするガードを追加した。

1
2
3
4
function updateThreshold() {
    if (siteMeta.classList.contains('fixed')) return; // ← ガード追加
    headerThreshold = siteMeta.getBoundingClientRect().top + window.scrollY;
}

同時に、以下の改善も加えた。

  • isActive フラグ: モバイル/デスクトップの切り替えを activate() / deactivate() で明示的に管理。デスクトップ幅にリサイズされた際にクラスやインラインスタイルを確実にクリーンアップする
  • レイアウトシフト防止: fixed 化する前に header.style.minHeight で元の高さを確保し、要素が fixed で浮いた際のガタつきを防ぐ

2. 品質・アクセシビリティ改善

transition の最適化

元のCSSでは transition: all 0.3s ease を使っていた。これだと background-colorz-index など意図しないプロパティまで遷移対象になる。ダークモード切り替え時に背景色がゆっくり変わるなどの副作用が起きるため、遷移させたいプロパティだけを明示的に指定するように変更した。

1
2
3
4
5
// Before
transition: all 0.3s ease;

// After
transition: padding 0.3s ease;

aria-hidden の動的トグル

スクロールでアバターやソーシャルリンクが opacity: 0 で非表示になっても、スクリーンリーダーからはまだアクセス可能な状態だった。視覚的に見えない要素がフォーカスされるのは混乱の原因になるため、非表示時に aria-hidden="true" を付与するようにした。

1
2
3
4
5
6
7
// 固定化時
siteAvatar.setAttribute('aria-hidden', 'true');
if (menuSocial) menuSocial.setAttribute('aria-hidden', 'true');

// 固定解除時
siteAvatar.removeAttribute('aria-hidden');
if (menuSocial) menuSocial.removeAttribute('aria-hidden');

オーバーレイのカスケード順序

メニュー展開時に背景を暗くするオーバーレイ(.menu-overlay)を JS で動的に生成している。デスクトップでは不要なので display: none を指定しているが、CSSのカスケード順序によってはモバイル用の display: block を上書きしてしまう。メディアクエリの外側display: none を置き、内側で display: block に上書きする構造に修正した。

3. アニメーション・トランジション強化

見た目のデザインは変えずに、操作感を向上させるアニメーションを8項目追加した。

# 項目 手法
1 ヘッダースライドイン @keyframes headerSlideDown で上からフェードイン
2 アバター縮小フェードアウト transform: scale(0.85) + opacity: 0
3 メニューイージング改善 cubic-bezier(0.4, 0, 0.2, 1) (Material Design標準)
4 オーバーレイぼかし backdrop-filter: blur(4px) ですりガラス風
5 メニュー項目スタガード nth-child + animation-delay で順番にフェードイン
6 スクロール連動シャドウ CSS変数 --shadow-progress + JS で段階的に影を強める
7 タップフィードバック :activescale(0.9) の縮小
8 reduced-motion対応 prefers-reduced-motion でアニメーション無効化

以下、実装のポイントを抜粋して解説する。

スクロール連動シャドウ

固定ヘッダーの影を、スクロール量に応じて0から段階的に強めたかった。CSS アニメーションだけでは「スクロール量」に連動できないため、JS で CSS カスタムプロパティ --shadow-progress を0〜1の値でセットし、CSS 側で calc() を使って box-shadow の透明度を制御する方式を採用した。

1
2
3
// JS: handleScroll() 内
var scrollPast = Math.min(scrollTop - headerThreshold, 80);
siteMeta.style.setProperty('--shadow-progress', (scrollPast / 80).toFixed(2));
1
2
3
4
5
// CSS: .site-meta.fixed
box-shadow:
    0px 4px 8px #{"rgb(0 0 0 / calc(0.04 * var(--shadow-progress, 1)))"},
    0px 0px 2px #{"rgb(0 0 0 / calc(0.06 * var(--shadow-progress, 1)))"},
    0px 0px 1px #{"rgb(0 0 0 / calc(0.04 * var(--shadow-progress, 1)))"};

スクロール開始から 80px で影が最大値に到達する。--shadow-progress のフォールバック値を 1 にしているので、JS が読み込まれる前でも影は最大値で表示される。

なお、Hugo の SCSS コンパイラ(Dart Sass)は rgb(0 0 0 / ...) を Sass の関数として解釈しようとする。#{"..."} で文字列補間することで、Sass をバイパスして生の CSS として出力させている。

また、transitionbox-shadow を含めると、JS がフレーム単位で更新する --shadow-progress の変化がなめらかに追従しなくなるため、transition 対象から box-shadow を除外している。

メニュー項目のスタガードアニメーション

メニューが開くとき、全項目が一斉に出現するのではなく、上から順番に少しずつ遅れてフェードインする。nth-childanimation-delay の組み合わせで実現した。

1
2
3
4
5
6
7
8
9
body.show-menu .left-sidebar #main-menu > li {
    opacity: 0;
    animation: menuItemFadeIn 0.3s cubic-bezier(0.4, 0, 0.2, 1) forwards;

    &:nth-child(1) { animation-delay: 0.03s; }
    &:nth-child(2) { animation-delay: 0.06s; }
    &:nth-child(3) { animation-delay: 0.09s; }
    // ... 30ms ずつ加算
}

セレクタに > li を使い、直接の子要素のみを対象にしている。ネストされた li(ダークモードトグルや言語切替など)は親の li と一緒に出現する。

prefers-reduced-motion 対応

アニメーション酔いなどの理由でモーションを減らしたいユーザーのために、prefers-reduced-motion: reduce メディアクエリで今回追加したカスタムアニメーションを無効化している。テーマ側のアニメーションには影響しない。

1
2
3
4
5
6
7
8
9
@media (prefers-reduced-motion: reduce) {
    .site-meta.fixed { animation: none; }
    body.show-menu .left-sidebar #main-menu > li {
        animation: none;
        opacity: 1;
    }
    .left-sidebar.scrolled .site-avatar { transform: none; }
    .menu-overlay { backdrop-filter: none; -webkit-backdrop-filter: none; }
}

4. iOS ダイナミックアイランド/ノッチ対応

問題

iPhone X 以降のノッチ搭載機や、iPhone 14 Pro 以降のダイナミックアイランド搭載機では、position: fixed; top: 0 の要素がノッチやダイナミックアイランドの背後に隠れることがある。特に Safari の URL バーがスクロールで縮小した際に顕著になる。

解決策

対応には2つの変更が必要になる。

1. viewport-fit=cover の追加

env(safe-area-inset-top) でセーフエリアの値を取得するには、viewport メタタグに viewport-fit=cover が必要。しかしこのメタタグはテーマの head.html 内にあり、直接編集したくない。そこで JS で動的に書き換える方法を採用した。

1
2
3
4
5
6
7
var viewport = document.querySelector('meta[name="viewport"]');
if (viewport) {
    var content = viewport.getAttribute('content');
    if (content && content.indexOf('viewport-fit') === -1) {
        viewport.setAttribute('content', content + ', viewport-fit=cover');
    }
}

2. env(safe-area-inset-top) の適用

固定ヘッダー、ハンバーガーボタン、スライドダウンメニューの3箇所にセーフエリア分のオフセットを追加した。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// 固定ヘッダー: 既存のパディングにセーフエリア分を加算
&.fixed {
    padding-top: calc(2px + env(safe-area-inset-top, 0px));
}

// ハンバーガーボタン: top 位置をオフセット
#toggle-menu {
    top: calc(10px + env(safe-area-inset-top, 0px));
}

// スライドダウンメニュー: 上部にパディング追加
#main-menu {
    padding-top: env(safe-area-inset-top, 0px);
}

env() のフォールバック値に 0px を指定しているため、ノッチがないデバイスでは値が 0 になり、レイアウトへの影響はない。

まとめ

テーマファイルを一切変更せず、assets/js/mobile-header.jsassets/scss/custom.scss の2ファイルだけで以下の改善を実現できた。

  • スクロールを繰り返すとヘッダーが消えるバグの修正
  • transition の最適化とアクセシビリティ対応
  • 8種類のアニメーション・トランジション追加
  • iOS のダイナミックアイランド/ノッチへの対応

Hugo のテーマオーバーライド機構を活用すれば、テーマのバージョンアップに影響されずにカスタマイズを維持できる。同じ Stack テーマを使っている人の参考になれば幸いだ。

発言は個人の見解であり、所属組織とは関係ありません。
Hugo で構築されています。
テーマ StackJimmy によって設計されています。