Media Queries See the Real Viewport in a Scaled Stage
※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。
結論
CSS @media evaluates the browser's real viewport width, not the visual size after a transform, so a stage fixed at 1280x720 and scaled down still picks up the large-monitor profile and overflows.
Conclusion
Shrink a fixed-size stage to an arbitrary width with transform: scale() and CSS @media still does not see that shrunken visual size. It evaluates the browser’s real device viewport width. In a preview wrapper that puts a 1280×720 stage through transform: scale() and clips the frame with overflow: hidden, a large-monitor @media(min-width: 1800px) profile on the board itself fires purely because the browser’s real viewport is 1800px or wider — even though the stage is pinned at 1280×720. The content then swells past the stage’s assumed size and the overflowing bottom edge is clipped away by overflow: hidden.
Symptom
ScaledSignageBoard is a wrapper that renders the signage board (SignageBoardView) at an arbitrary width while keeping it identical to the real device. The content sits on a fixed 1280×720 .stage, and the outer .frame applies transform: scale(var(--sb-scale)). .frame carries overflow: hidden and reserves layout with 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));
}
The wrapper is used by the editor’s true-scale preview. It draws the very same board component the real signage uses (SignageBoardView’s .signageRoot) while shrinking it to fit the page.
Open that preview on a staff-room monitor at full HD or larger and the bottom of the board — which should fit inside 1280×720 — is cut off. It reproduces without changing zoom or window size, purely from a larger monitor resolution.
Cause
.signageRoot carries responsive definitions written for the real signage device — a large monitor showing the board full screen — that adjust element sizes to the monitor’s resolution.
/* Large monitor (full 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;
}
}
That is written for the actual signage terminal, where the viewport equals the physical display, and there the assumption holds.
But ScaledSignageBoard pins that same .signageRoot inside a 1280×720 .stage and draws it scaled down. @media does not evaluate against the transform applied to an element; it evaluates the browser’s actual width. So with the stage fixed at 1280×720, a real viewport of 1800px or more still fires @media(min-width: 1800px), and --scale-factor: 1.35 and friends are applied to .signageRoot.
The content therefore tries to draw larger than the 1280×720 it was designed for. The .stage width and height stay at 1280px and 720px, so only the content spills over, and the outer .frame’s overflow: hidden clips the overflowing bottom edge.
The fix
Hard-code the 720p baseline values in CSS custom properties scoped to the stage, neutralising through cascade order what the outer @media tries to write.
First, ScaledSignageBoard.tsx adds a marker class .scaledStage to .stage.
<div className={scaler.frame} style={frameStyle}>
{/* .scaledStage: inside the stage, neutralise the large-monitor profiles from §12
(>=1800px = x1.35 / >=3000px = x2) and pin the 720p baseline (signage.module.css §15).
Without this, a staff-room full HD screen pushes the board past 1280x720 and the
frame's overflow:hidden clips it. */}
<div className={`${scaler.stage} ${boardStyles.scaledStage}`}>
<SignageBoardView
data={payload}
ad={ad}
...
Then signage.module.css gains rules targeting only .signageRoot beneath .scaledStage.
.scaledStage .signageRoot {
--scale-factor: 1;
--content-gap: 8px;
}
.scaledStage .signageRoot:not(.p3Root):not(.p4Root):not(.p5Root) {
--header-height: 36px;
}
--scale-factor: 1 and --content-gap: 8px are the same values each property defaults to when the large-monitor @media does not apply. In the cascade, at equal specificity the later rule wins. .scaledStage .signageRoot has the same specificity as .signageRoot inside the @media, but placing it later in the stylesheet overrides what the real-viewport query wrote, pinning everything inside the stage to the 720p baseline.
Header height could not be fixed so simply. Patterns 3, 4 and 5 set their own header heights (64px, 58px and 64px) by class rather than through @media, so overriding --header-height uniformly would squash those patterns. :not(.p3Root):not(.p4Root):not(.p5Root) excludes them and pins 36px for the rest.
No JavaScript viewport detection and no ResizeObserver were added. Overriding CSS custom properties scoped to one DOM region is enough to stop behaviour that depends on the outer real viewport.
Preventing a repeat
The fix carries a comment describing the mechanism itself.
/* {@link ScaledSignageBoard} puts the board on a fixed 1280x720 stage and shrinks it to any
width with transform: scale(). But the responsive rules in §12 evaluate @media against the
real device viewport, so even with a 1280x720 stage a staff-room full HD screen (>=1800px)
applies --scale-factor:1.35 (and x2 at 4K), the board overflows the 720p stage and the frame's
overflow:hidden clips the bottom. Pin the 720p baseline inside the stage so that opening it on
a large monitor still yields a faithful shrunken copy of 1280x720. */
The same repository has another component, fitStage, pinning a 1920×1080 stage to the 1080p profile (--scale-factor: 1.35) with the same countermeasure. As long as the design shrinks fixed-size content to an arbitrary width with transform: scale(), every component hits the same constraint: @media looks at the real viewport, not the transformed appearance. Whoever builds a fixed-size stage has to pin its custom properties explicitly and keep it independent of the outer responsive definitions.
よくある質問
Q1Why does a large-monitor @media fire inside a fixed stage shrunk with transform:scale?
@media(min-width) evaluates the browser's actual viewport width, not the element size after a transform. Even with the stage fixed at 1280x720 and drawn scaled down, a browser on a monitor 1800px or wider fires the query and overrides the profile the scaling assumed.
Q2Did the fix need JavaScript to detect the viewport width?
No. CSS custom properties scoped to the stage hard-code the 720p defaults, neutralising the outer @media through cascade order. No detection and no resize observer were added.
Q3Does this affect other fixed-size transform:scale components?
Yes. The same repository has a fitStage component pinning a 1920x1080 stage to the 1080p profile with the same countermeasure. As long as the design shrinks a fixed size with transform:scale, @media looking at the real viewport is unavoidable.
確認した環境
- Next.js ^16 / React ^19 (apps/web)
- Fixed in a commit on 2026-07-12 (present since the original implementation)
この記事の根拠
- TypeScriptファイル 80〜89行目コミット a9f97dd
- CSSファイル 2487〜2514行目コミット a9f97dd
本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。