Rebounder Tech Blog

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

transform:scaleで固定サイズに縮小表示していても、メディアクエリは実ビューポート幅で評価される

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

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

結論

CSSの@mediaはtransformで縮小された見た目のサイズではなく、ブラウザの実ビューポート幅そのものを評価するため、1280×720に固定してscale()で縮小描画していても、実機が大型モニタなら大画面向けプロファイルがそのまま適用されてステージをはみ出す。

結論

transform: scale() で固定サイズのステージを任意の幅へ縮小描画していても、CSSの @media はその縮小後の見た目のサイズを見ない。ブラウザの実デバイスのビューポート幅そのものを評価する。 1280×720のステージを transform: scale() で縮小し、外枠を overflow: hidden で切り取るプレビュー用ラッパーで、盤面本体に大型モニタ向けの @media(min-width: 1800px) プロファイルが仕込まれていると、ステージ自体は1280×720に固定されているにもかかわらず、開いているブラウザの実ビューポート幅が1800px以上あるだけでそのプロファイルが発火し、盤面の内容がステージの想定サイズを超えて膨らみ、はみ出した下端が overflow: hidden で見切れる。

症状

サイネージ盤面(SignageBoardView)を実機と同じ見た目のまま任意の幅に縮小表示する ScaledSignageBoard というラッパーコンポーネントがある。中身は1280×720の固定サイズの .stage に置き、外側の .frame に対して transform: scale(var(--sb-scale)) で任意幅へ縮小する。.frameoverflow: hidden を持ち、aspect-ratio: 16 / 9 でレイアウト領域を確保する。

.frame {
  position: relative;
  width: 100%;
  aspect-ratio: 16 / 9;
  overflow: hidden;
  ...
}
.stage {
  position: relative;
  width: 1280px;
  height: 720px;
  transform-origin: top left;
  transform: scale(var(--sb-scale));
}

このラッパーはエディタの実寸プレビュー(apps/web/app/app/editor/[classId]/preview/page.tsx)で使われている。実機のサイネージと同じ盤面コンポーネント(SignageBoardView.signageRoot)をそのまま描画しつつ、任意の幅に縮小して画面内に収める設計になっている。

ところが、職員室のフルHD以上のモニタでこのプレビューを開くと、1280×720に収まっているはずの盤面の下端が切れて表示された。ブラウザの拡大率やウィンドウ幅を変えなくても、単にモニタの解像度が大きいだけで再現する。

原因

.signageRoot には、実機のサイネージ(大型モニタでフル表示する用途)向けに、モニタの解像度に応じて要素のサイズを調整するレスポンシブ定義が入っている。

/* 大型モニタ(フル HD) */
@media (min-width: 1800px) and (min-height: 1000px) {
  .signageRoot {
    --scale-factor: 1.35;
    --content-gap: 12px;
    --header-height: 46px;
  }
}
/* 4K */
@media (min-width: 3000px) {
  .signageRoot {
    --scale-factor: 2;
    --content-gap: 20px;
    --header-height: 64px;
  }
}

これは実機のサイネージ端末(ビューポート=物理的な表示サイズがそのまま一致する)を対象に書かれたもので、実機ではその前提が成り立つため正しく機能する。

しかし ScaledSignageBoard は、この同じ .signageRoot を1280×720の .stage に固定して transform: scale() で縮小描画するラッパーだった。@media は要素に適用された transform を見て評価するわけではなく、ブラウザ(あるいはビューポートを提供している祖先要素)の実際の幅を評価する。したがって、ステージ自体は1280×720に固定されていても、それを開いているブラウザの実ビューポート幅が1800px以上であれば @media(min-width: 1800px) は普通に発火し、--scale-factor: 1.35 等の大型モニタ向けの値が .signageRoot に適用されてしまう。

結果として、盤面の内容は1280×720という前提サイズより大きく描画されようとする。だが .stage の幅・高さそのものは1280px・720pxに固定されているため、内容だけが枠をはみ出し、外側 .frameoverflow: hidden によってはみ出した下端が切り取られて見える。

直し方

ステージ内だけを対象にしたCSSカスタムプロパティで、720p基準のデフォルト値(等価な値)を固定書きし、外側の @media が上書きしようとする値をカスケードの順序で中和した。

まず ScaledSignageBoard.tsx.stage に、ステージであることを示すマーカークラス .scaledStage を追加で付与する。

<div className={scaler.frame} style={frameStyle}>
  {/* .scaledStage: ステージ内は §12 の大型モニタプロファイル(≥1800px=×1.35 / ≥3000px=×2)を中和し
      720p 基底プロファイルを固定する(signage.module.css §15)。これがないと職員室のフル HD 画面で
      盤面が 1280×720 を超過し frame の overflow:hidden で見切れる。 */}
  <div className={`${scaler.stage} ${boardStyles.scaledStage}`}>
    <SignageBoardView
      data={payload}
      ad={ad}
      ...

そのうえで signage.module.css に、.scaledStage 配下の .signageRoot だけを対象にしたルールを追記する。

.scaledStage .signageRoot {
  --scale-factor: 1;
  --content-gap: 8px;
}
.scaledStage .signageRoot:not(.p3Root):not(.p4Root):not(.p5Root) {
  --header-height: 36px;
}

--scale-factor: 1--content-gap: 8px は、各カスタムプロパティのデフォルト値(大型モニタ向け @media が適用されない場合の既定挙動)と同じ値である。CSSのカスケードにおいて、詳細度が同等なら後に書かれたルールが勝つ。.scaledStage .signageRoot はセレクタの詳細度自体は @media 内の .signageRoot と同じだが、スタイルシート内でより後方に置くことで、実ビューポート由来の @media が書き込んだ値を上書きし、ステージ内だけを常に720p基準に固定する。

ヘッダー高さだけは単純な固定にできなかった。pattern3・pattern4・pattern5という3種類の盤面パターンは、@media の値ではなくクラス直指定で専用のヘッダー高(それぞれ64px・58px・64px)を持っているため、--header-height を一律に上書きするとこれらのパターンが本来より低いヘッダーに潰れてしまう。そこで :not(.p3Root):not(.p4Root):not(.p5Root) で3パターンを対象から除外し、残りのパターンだけに36pxのデフォルト値を固定している。

JavaScriptでビューポート幅を検出したり、ResizeObserver で監視したりする対応は入れていない。ステージという特定のDOM範囲にスコープしたCSSカスタムプロパティの上書きだけで、外側の実ビューポートに依存する挙動を止めている。

再発防止

修正のコメントには、なぜこの対策が必要になったかという仕組みそのものを残した。

/* {@link ScaledSignageBoard} は盤面を 1280×720 の固定ステージに置き transform: scale() で任意幅へ縮小する。
   だが §12 のレスポンシブは @media=実デバイスのビューポートで評価されるため、ステージが 1280×720 でも
   職員室のフル HD 画面(≥1800px)では --scale-factor:1.35 等(4K の ×2 も同様)が効き、盤面が 720p ステージを
   はみ出して frame の overflow:hidden で下端が見切れる。ここでステージ内は 720p 基底プロファイルを固定し、
   大型モニタで開いても 1280×720 に忠実に収まる縮小コピーにする。 */

同じリポジトリには、1920×1080のステージを1080pプロファイル(--scale-factor: 1.35)で同様に固定する fitStage という別コンポーネントもあり、同型の対策が入っている。transform: scale() で固定サイズのコンテンツを任意幅に縮小表示するという設計を採る限り、「@media は変形後の見た目ではなく実ビューポートを見る」という制約はどのコンポーネントでも共通して踏む。固定サイズのステージを作る側では、ステージ内のカスタムプロパティを明示的に固定し、外側のレスポンシブ定義から独立させておく必要がある。

よくある質問

Q1transform:scaleで固定サイズに縮小表示しているのに、なぜ大型モニタ向けの@mediaが効いてしまうのですか?

@media(min-width)はtransformで見た目の大きさが変わった後の要素サイズではなく、ブラウザの実際のビューポート幅を評価します。ステージを1280×720に固定してscale()で縮小描画していても、開いているブラウザ自体が1800px以上の大型モニタであれば実ビューポート基準で@mediaが発火し、スケール前提のプロファイルが上書きされます。

Q2直すのにJavaScriptでビューポート幅を検出する必要はなかったのですか?

不要でした。ステージ内だけに範囲を絞ったCSSカスタムプロパティで720p基準のデフォルト値を固定書きし、外側の@mediaが後から上書きしようとしていた値をカスケードの順序で無効化しています。JSでの検出やresize監視は一切追加していません。

Q3同じ問題は他の固定サイズ+transform:scaleのコンポーネントにも起きますか?

起きます。同じリポジトリには1920×1080のステージを1080pプロファイルで固定するfitStageという別コンポーネントがあり、同型の対策が入っています。transform:scaleで固定サイズを縮小表示する設計である限り、@mediaが実ビューポートを見るという性質は避けられません。

確認した環境

  • Next.js ^16 / React ^19(apps/web)
  • 2026-07-12 の修正コミットで解消(発生は実装当初から)

この記事の根拠

  • TypeScriptファイル 80〜89行目コミット a9f97dd
  • CSSファイル 2487〜2514行目コミット a9f97dd

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