← ./articles-ja

CSSレイアウト計測の罠: zoom、transform、min-width、inline style

レイアウト崩れは、見えているCSSルールそのものではなく、ブラウザがどう寸法を測るか、grid/flex itemがどこまで縮むか、inline styleがmedia queryに勝つかで起きることがあります。

この記事では、レスポンシブUIで何度も問題になる4つの罠を扱います。

  • zoomtransform: scale() はlayoutへの影響が違う
  • offsetHeightgetBoundingClientRect() は別の質問に答える
  • grid/flex itemの中に広いcontentがある時は min-width: 0 が必要
  • style={{ display: "flex" }} はmedia queryの非表示指定に勝つ

zoomとtransformは役割が違う

zoom は要素を拡大縮小し、layout flowにも影響します。transform: scale() は見た目を変えますが、兄弟要素のために確保されるlayout spaceは変えません。

JavaScriptでcontrolの高さを測り、別要素の位置決めに使う場合、この違いが効きます。

const height = element.offsetHeight;

offsetHeight はlayout boxの値です。render後の見た目の高さとはズレることがあります。

const height = element.getBoundingClientRect().height;

getBoundingClientRect() はrendered geometryの値です。overlay、sticky control、terminal pane、mobile dock、animated barでは、こちらが必要なことが多いです。

表示位置にはgetBoundingClientRectを使う

下部control barの高さを親layoutへ伝えるなら、見た目の高さを返します。

const observer = new ResizeObserver(() => {
  const height = controlRef.current?.getBoundingClientRect().height ?? 0;
  onHeightChange(height);
});

if (controlRef.current) {
  observer.observe(controlRef.current);
}

これは次の要素で特に重要です。

  • zoom
  • CSS transform
  • 見た目のsizeが変わるtransition
  • mobile viewport変化
  • browserのdesktop site表示

offsetHeight に推測したzoom倍率を掛けるより、render後の結果を直接測ります。

広い子要素を持つgrid/flex itemにはmin-width: 0を入れる

1fr のgridでも、1つのcode blockで横overflowすることがあります。

原因はgrid/flex itemのdefault min-width: auto です。itemが子要素のintrinsic widthより小さく縮まないためです。

.article-layout {
  display: grid;
  grid-template-columns: 1fr;
}

.article-layout > * {
  min-width: 0;
}

pre {
  overflow-x: auto;
}

flexでも同じです。

.row {
  display: flex;
}

.row > * {
  min-width: 0;
}

対象になりやすい子要素です。

  • <pre> code block
  • table
  • 長いURL
  • spaceなしの長いtoken
  • chartやSVG

目的はoverflowを全体で隠すことではありません。親trackを縮め、広い子要素だけを自分のbox内でscrollさせることです。

inline styleはmedia queryを負かす

React inline styleは優先度が高いです。

<div className="desktop-toolbar" style={{ display: "flex" }}>
  ...
</div>

このmedia queryでは隠れません。

@media (max-width: 767px) {
  .desktop-toolbar {
    display: none;
  }
}

inlineの display: flex が勝つためです。display指定はclassへ移します。

<div className="desktop-toolbar">...</div>
.desktop-toolbar {
  display: flex;
}

@media (max-width: 767px) {
  .desktop-toolbar {
    display: none;
  }
}

!important で戦うより、layout propertyの所有場所をclass側に寄せるほうが保守しやすいです。

mobile wake時のvisualViewport値を検証する

mobile browserは、device wakeやpage restore直後に変な visualViewport.height を返すことがあります。

visualViewport を使うなら、物理的にありえない値を捨てます。

function isValidViewportHeight(vvHeight: number, innerHeight: number) {
  return vvHeight >= innerHeight * 0.35;
}

function updateFromViewport() {
  const viewport = window.visualViewport;
  if (!viewport) return;

  if (!isValidViewportHeight(viewport.height, window.innerHeight)) {
    return;
  }

  updateLayout(viewport.height);
}

wakeやrestoreでは、一度の requestAnimationFrame より短いtimeoutのほうが安定する場合があります。

デバッグチェック

レスポンシブlayoutがclip/overflowする時は、順に確認します。

  1. offsetHeightgetBoundingClientRect() を比べる
  2. 測っている要素に zoom やtransformがあるか見る
  3. 広いcontentを持つgrid/flex itemに min-width: 0 を入れる
  4. media queryで制御したいpropertyにinline style がないか探す
  5. placeholderではなく実データで狭いviewportを試す

参考