Rebounder Tech Blog

運用している当事者が書く、本番システムの記録。

cqhはsize containerが無いとviewportへフォールバックして文字が2倍に膨張した

公開 読了時間 約7分執筆: Rebounder 開発チーム(当該システムの運用当事者)

※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。

結論

cqhは`container-type: size`の祖先が無いとき無効になるのではなく、次のsize container、無ければビューポートの高さに解決される。

結論

container-type: sizeの祖先が無い経路でcqhを使っても、エラーにはならない。次に見つかったsize container、それも無ければsmall viewportの高さに静かに解決される。「container-type: normalにすればcqhが無効になる」という思い込みのまま、スクロール表示に切り替えるための既存のモバイル分岐(あえてcontainer-type: normalにしていた)を見落とし、同じフォントサイズ暴走を再発させた。

症状

キミテラスのサイネージ盤面(壁掛けTV向け)は、予定一覧の各行フォントサイズを100cqh / var(--visible-rows)で行の高さに比例させている。デスクトップ・TV実機では問題なく、行高に対して文字が占める割合(係数0.48=行の約60%)も実機で確認済みだった。

2026-09-10、公開/signageをスマホ(≤899px)で開くと、科目名が1文字も表示されず、連絡・提出物欄も画面外へ押し出される崩れが実機で見つかった。フォントサイズがclamp()の上限3rem(48px)まで膨張していたのが原因だった。管理画面の編集プレビューではこの崩れは再現していない。

原因

モバイル幅(≤899px)には、縦積みスクロール表示に切り替えるための既存の@media分岐があり、そこで予定行のsize containerである.autoScrollをあえてcontainer-type: normalに戻していた。固定行高をやめ、中身を自然高さで全件流すための意図的な変更で、今回のcqh移行より前から存在する。

/* signage.module.css: 2376-2382(修正前時点・内容は修正後も不変) */
/* モバイルは縦積みでページスクロールするので、固定枠ビューポート(§11a)の固定窓・固定行高は無効化し、
   中身を自然高さで全件流す(カードの overflow:visible と一貫)。予定/連絡の固定枠は壁(TV)専用。 */
.autoScroll {
  container-type: normal;
  overflow: visible;
}
.autoScrollItem {
  height: auto;
}

このモバイル分岐を書いた側のコメントに、まさに今回の思い込みが残っていた。

/* signage.module.css: 289-312(修正前)
.scheduleListItem {
  /* cqh フォールバック: container query 非対応 WebView は前者 (vw 版) を、
     対応ブラウザは後者 (スクロール領域高さ基準) を採用する progressive enhancement。
     意図的な二重宣言。 */
  font-size: clamp(0.78rem, 1.3vw, 1.2rem);
  /* …
     ⚠ モバイル(≤899px)は `.autoScroll` が `container-type: normal` になり
        cqh が無効=上の vw 版に自動で落ちる。 */
  /* biome-ignore lint/suspicious/noDuplicateProperties: cqh フォールバック (非対応は前者の vw 版) */
  font-size: clamp(0.7rem, calc(100cqh / var(--visible-rows, 7) * 0.48), 3rem);
  …
}

この二重宣言は、もともと「container queryに非対応のWebViewでは1行目(vw版)が、対応ブラウザでは2行目(cqh版)が採用される」という、非対応ブラウザ向けのprogressive enhancementだった。モバイルのcontainer-type: normalはまったく別の話で、ブラウザはcontainer queryに対応したまま、ただ.autoScrollがsize containerでは無くなるだけの状態になる。

ここで実際に起きるのは、「cqhが無効化されてvw版に落ちる」ではない。cqhはsize containerが祖先に無くても、祖先をさらに遡って次のsize container、それも無ければsmall viewportの高さに対して、有効な値として計算され続ける。 今回は.autoScrollより上に他のsize containerが無かったため、100cqhはビューポートの高さ(844px)を基準に計算された。宣言順で後に書かれている2行目(cqh版)は依然として有効な値を持つので、そのまま採用され続け、1行目(vw版)に落ちることはなかった。

さらに、.autoScroll .scheduleListItem(詳細度0,2,0)は、同じモバイル幅向けに別セクションで定義されていた.scheduleListItem単体への@media (max-width: 899px)ルール(0,1,0)より詳細度が高い。そちらにフォントサイズの上書きを足しても、祖先セレクタ付きのcqh宣言には勝てない。

直す

個別のクラス名やメディアクエリで後追いの上書きを積むのではなく、size containerが祖先に存在する経路でだけ、このフォントサイズ宣言自体をパースさせるという構造に変更した。

/* signage.module.css: 907-939(修正後) */
.autoScroll {
  flex: 1;
  min-height: 0;
  overflow: hidden;
  container-type: size;
  display: flex;
  flex-direction: column;
}
.autoScrollItem {
  height: calc(100cqh / var(--visible-rows));
  box-sizing: border-box;
}

@container (min-height: 0px) {
  .autoScroll .scheduleListItem {
    font-size: clamp(0.7rem, calc(100cqh / var(--visible-rows, 7) * 0.48), 3rem);
  }
}

@containerクエリは、祖先にquery containerが1つも存在しない要素に対しては条件自体が成立せず、中の宣言ごとスキップされる。 モバイル分岐で.autoScrollがcontainer-type: normalになっている経路ではquery containerが無いため、この@containerブロックは丸ごと適用対象から外れ、.autoScroll .scheduleListItemのcqh宣言そのものが効かなくなる。結果として、モバイルでは1行目のfont-size: clamp(0.78rem, 1.3vw, 1.2rem)(vw版)だけが残る。

デスクトップ・TV実機側(.autoScrollがcontainer-type: sizeのまま)ではquery containerが存在するため、この@containerブロックは普通に適用され、これまでと同じ行高比例のフォントサイズになる。

/* signage.module.css: 2611-2615(修正後・管理画面の編集プレビュー専用) */
.scaledStage .autoScroll {
  container-type: size;
}
.scaledStage .autoScrollItem {
  …
}

管理画面の編集プレビュー(.scaledStage)は別経路で、≤899px幅でも.autoScrollにcontainer-type: sizeを復元する上書きを持っている。固定の16:9ステージとして見せるための既存の仕組みで、このおかげでプレビュー上は実機のモバイル分岐を通らない。そのため今回の不具合はプレビューでは再現せず、実機のスマホで公開URLを開いて初めて見つかっている。

再発防止

container-type: normalは「このクラスをサイズコンテナにしない」という意味であって、cqhそのものを無効化する機能ではない。cqh/cqw/cqiなどコンテナクエリ単位は、直近のsize containerが見つからなければ祖先をさらに遡り、最終的にsmall viewportへ解決される。「コンテナが無ければ無効」という直感は実際の仕様とは違い、一度コメントに書き残していても、その場では気づきにくい。

同じクラスを複数のcontainer-typeの経路(デスクトップ=size、モバイル=normal、管理画面プレビュー=別経路でsizeに復元)で使い回す場合は、「そのクラスのコンテナクエリ単位を含む宣言が、size containerの無い経路に漏れていないか」を経路ごとに確認する必要がある。個別のクラス名やメディアクエリで後追いの上書きを積むより、@containerクエリで宣言そのものを「size containerがある経路専用」に区切るほうが、将来似た経路が増えても漏れを防ぎやすい。

よくある質問

Q1cqhがsize containerなしで使われると何が起きますか?

エラーにはならず、次に見つかったsize containerか、無ければsmall viewportの高さに対して100cqhが解決されます。コンテナが無いから無効、という挙動ではありません。

Q2container-type: normalにすればcqhは無効化できますか?

できません。container-type: normalはそのクラスをサイズコンテナにしないだけで、cqh自体は祖先をさらに遡って次のsize container、無ければビューポートの高さに解決されます。キミテラスの実装ではこの前提違いにより、モバイル分岐で文字サイズが3rem上限(48px)まで暴走しました。

Q3なぜ既存のvw版フォールバック宣言が効かなかったのですか?

progressive enhancementのための二重宣言はcontainer query非対応ブラウザ向けの対処で、対応ブラウザでcqhがviewport基準に解決されるケースは想定していませんでした。宣言順でcqh側が後に書かれているため、対応ブラウザでは常にcqh側が採用されます。

Q4@container (min-height: 0px)はなぜ効くのですか?

@containerクエリはquery containerが祖先に存在しない要素には条件自体が成立せず中の宣言ごとスキップされます。これを使うとsize containerがある経路だけで効かせることをCSSだけで実現できます。

確認した環境

  • Next.js ^16.0.0 / React ^19.0.0(キミテラス-v2 signage.module.css)
  • 2026-09-10、約52分差で発見・修正。実機のスマホで公開 /signage を開いて発見し、管理画面の編集プレビューでは再現しなかった。

この記事の根拠

  • CSSファイル 289〜312行目コミット c4f0a31
  • CSSファイル 2376〜2382行目コミット c4f0a31
  • CSSファイル 907〜939行目コミット eda0843
  • CSSファイル 2611〜2615行目コミット eda0843

本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。