zudo-css-wisdom

Type to search...

to open search from anywhere

ディスプレイスケール戦略

作成2026年4月6日更新2026年4月24日Takeshi Takatsudo

問題

Web 技術で構築されたデスクトップアプリ(Tauri、Electron)は、WebView 内でレンダリングされます。ユーザーはネイティブアプリと同じズーム操作を期待します:Ctrl+スクロールや Ctrl+/- で UI 全体を拡大縮小する機能です。

明白なアプローチはブラウザのネイティブズームを使うことですが、WebView 環境ではネイティブズームは問題を引き起こします:

  • カーソル位置のずれ — 100% 以外のズームでは、WebView が報告するカーソル位置が実際の画面位置からずれる。ドラッグハンドル、リサイズパネル、クリックターゲットが不整合になる
  • 制御できないスケーリング — ネイティブズームはすべてを均一にスケーリングする。どのスケールでもシャープであるべきヘアラインボーダーやボーダーラディウスの値も含めて
  • 永続化 API がない — WebView API でズームレベルをセッション間で保存・復元する標準的な方法がない

結果:ユーザーがズームインすると、パネルがドラッグできなくなり、ボタンがクリックできなくなり、アプリの再起動でズームがリセットされます。ユーザビリティを向上させるはずの機能が、アプリを壊れたように感じさせてしまいます。

なぜ transform: scale() ではダメなのか

次に思いつくのは、アプリのルート要素を transform: scale() でラップすることです:

#app-root {
  transform: scale(var(--zoom));
  transform-origin: top left;
}

これはカーソル位置のずれを回避しますが、新たな問題を生みます:

  • 要素のレイアウトサイズが変わらない — 拡大されたアプリがコンテナからはみ出す
  • transform された祖先要素の中で fixed/sticky ポジショニングが壊れる
  • 非整数のスケールファクターでテキストがぼやける
  • コンテナに逆数の寸法を計算して適用する必要がある

ネイティブズームも transform スケールも、UI を外側からスケーリングしようとしています。ディスプレイスケール戦略は内側からスケーリングします。

解決方法

ブラウザのズームを単一の CSS カスタムプロパティ — --display-scale — に置き換え、すべてのデザイントークンにそれを掛け合わせます:

:root {
  --display-scale: 1; /* default: 100% */
}

ズームに連動すべきすべてのトークンが calc() を使います:

:root {
  --spacing-md: calc(8px * var(--display-scale));
  --spacing-lg: calc(12px * var(--display-scale));
  --font-size-base: calc(14px * var(--display-scale));
  --toolbar-height: calc(52px * var(--display-scale));
}

--display-scale1 から 1.25 に変わると、すべての値が即座に再計算されます。8px のギャップは 10px に、14px のフォントは 17.5px に、ツールバーは 52px から 65px に拡大します。UI 全体が単一の CSS リフローでスムーズかつ比例的にスケーリングされます。JavaScript によるレイアウト再計算は不要です。

スケールステップの定義

連続的なスケーリングよりも離散的なステップのほうが適しています。事前定義されたセットにより、中途半端なサイズを防ぎ、機能の挙動を予測可能にします:

const VALID_SCALES = [0.75, 0.9, 1.0, 1.1, 1.25, 1.5, 1.75, 2.0];

ズームイン・アウトでこれらのステップを順に切り替えます。ユーザーは設定画面やキーボードショートカットでスケールを選択し、アプリは選択を永続化して起動時に適用します。

スケールの適用

JavaScript 側の処理は最小限です。ルート要素に CSS プロパティを1つ設定するだけです:

function applyDisplayScale(scale: number): void {
  document.documentElement.style.setProperty(
    "--display-scale",
    String(scale)
  );
}

ネイティブズームが干渉しないよう、デフォルトのズームジェスチャーをインターセプトします:

// Prevent native zoom, dispatch custom events instead
window.addEventListener("wheel", (e) => {
  if (e.ctrlKey) {
    e.preventDefault();
    dispatchScaleChange(e.deltaY > 0 ? "out" : "in");
  }
}, { passive: false });

カスタムイベントがスケールステップのロジックをトリガーし、新しい値を永続化して applyDisplayScale() を呼び出します。残りは CSS が処理します。

トークンアーキテクチャ

重要なデザイン判断は何をスケーリングし、何をスケーリングしないかです。

スケーリングする値

ユーザーが「UI」として認識するものはすべてスケーリングすべきです:

カテゴリ計算式
スペーシングpadding、margin、gapcalc(Npx * var(--display-scale))
フォントサイズ本文、見出し、ラベルcalc(Npx * var(--display-scale))
アイコンサイズツールバーアイコン、ステータスアイコンcalc(Npx * var(--display-scale))
コンポーネントサイズボタン、チェックボックス、ツールバーの高さcalc(Npx * var(--display-scale))

固定のままにする値

スケールに関係なく一定であるべき値もあります:

カテゴリ固定にする理由
1px ヘアライン区切り線やセパレーターはシャープなままであるべき--spacing-1px: 1px
ボーダーラディウス角丸はスタイルの選択であり、サイズではない--radius-md: 4px
ボーダー幅細いボーダーはどのスケールでも明瞭さを保つborder: 1px solid
オーバーレイの不透明度暗くする効果はサイズではなく知覚に基づくrgba(0, 0, 0, 0.6)

判断基準:その値がどれくらい大きいかを定義しているならスケーリングする。どう見えるか(角丸、ボーダー、不透明度)を定義しているなら固定のままにする。

デモ

スケーリングされたトークン vs 固定トークン

このデモは同じ UI を3つの異なるディスプレイスケールで表示しています。スペーシング、テキスト、アイコンが比例的にスケーリングされる一方、ボーダーやボーダーラディウスは固定のままであることに注目してください。

Display Scale: 75%, 100%, 125%

固定値 vs スケーリング値

このデモは、スケーリングされる値と固定のままの値の違いを示しています。ボーダー、ボーダーラディウス、区切り線の太さは一定のまま — コンテンツの寸法だけが変わります。

What Scales vs What Stays Fixed

Tailwind v4 との統合

タイトトークン戦略を使う Tailwind v4 プロジェクトでは、@theme でベース値を宣言し、:root でスケーリングされた値でオーバーライドします:

/* tokens.css */
@theme {
  /* Base values — used by Tailwind for class generation */
  --spacing-sm: 6px;
  --spacing-md: 8px;
  --spacing-lg: 12px;
  --spacing-xl: 16px;

  --font-size-sm: 12px;
  --font-size-base: 14px;
  --font-size-lg: 16px;

  --spacing-icon-sm: 12px;
  --spacing-icon-md: 16px;
  --spacing-icon-lg: 20px;
}

:root {
  --display-scale: 1;

  /* Runtime overrides — multiply by display-scale */
  --spacing-sm: calc(6px * var(--display-scale));
  --spacing-md: calc(8px * var(--display-scale));
  --spacing-lg: calc(12px * var(--display-scale));
  --spacing-xl: calc(16px * var(--display-scale));

  --font-size-sm: calc(12px * var(--display-scale));
  --font-size-base: calc(14px * var(--display-scale));
  --font-size-lg: calc(16px * var(--display-scale));

  --spacing-icon-sm: calc(12px * var(--display-scale));
  --spacing-icon-md: calc(16px * var(--display-scale));
  --spacing-icon-lg: calc(20px * var(--display-scale));
}

@theme ブロックは Tailwind がクラス生成に必要なベース値を提供します。:root ブロックはそれらをランタイムでスケーリングされたバージョンでオーバーライドします。p-lgtext-basew-icon-md などの Tailwind クラスは自動的にスケーリングされた値を使用します。コンポーネントコードの変更は不要です。

一回限りのスケーリング値

トークンセットにないコンポーネント固有の寸法には、任意の値で calc() を直接使います:

<!-- Scaled arbitrary value -->
<div class="w-[calc(240px*var(--display-scale))]">Sidebar</div>

<!-- Or define a component token -->
<style>
  .sidebar { width: calc(240px * var(--display-scale)); }
</style>

よくある間違い

  • ボーダーラディウスをスケーリングする — 4px の角丸はどのスケールでも適切に見える。125% で 5px にスケーリングしても価値はなく、異なるスケールの要素を比較したときに不整合が生じる
  • 1px ボーダーをスケーリングする — ヘアラインボーダーは非整数スケールでぼやける。1px のままにすることで視覚的な明瞭さを維持する
  • ネイティブズームの防止を忘れる — Ctrl+スクロールや Ctrl+/- をインターセプトしないと、ネイティブズームとカスタムスケールの両方がトリガーされ、二重スケーリングになる
  • 連続的なスケールを使う — 自由形式のスケール値(例:1.137)はピクセル丸めのアーティファクトを生む。代わりに事前定義されたステップセットを使う
  • スケーリングされた値とされていない値を混在させる — 1つのコンポーネントで var(--spacing-lg) の代わりに gap: 12px を使うと一貫性が崩れる。ディスプレイスケールを採用したら、すべてのスペーシングがシステムを通る必要がある
  • transform: scale() でスケーリングする — 要素のレイアウトサイズが変わらず、fixed/sticky ポジショニングが壊れ、テキストがぼやける。トークンベースのスケーリングの代替にはならない

いつ使うか

  • デスクトップ Web アプリ — Tauri、Electron、またはユーザーがズーム機能を期待する WebView ベースのデスクトップアプリケーション
  • ドラッグ・リサイズ操作のあるアプリ — ネイティブズームはドラッグハンドル、パネルリサイザー、キャンバス操作のカーソル位置を壊す
  • タイトトークンを持つデザインシステム — すべての値が CSS カスタムプロパティを通っていれば、ディスプレイスケールは自然に統合される
  • UI 密度をユーザーが設定できるアプリ — 同じメカニズムがアクセシビリティズーム、コンパクトモード、大きなテキストモードをサポートする

使わないほうがよい場合

  • 通常のブラウザで表示される Web サイト — 標準的な Web コンテキストではブラウザのズームが正しく動作する。カスタムズームを追加すると体験が悪化する
  • 静的コンテンツサイト — ブログ、ドキュメント、マーケティングページにはドラッグ操作がなく、ネイティブズーム+レスポンシブデザインの恩恵を受ける
  • 全体的に rem を使っているアプリ — すべての値が rem ベースなら、ルートの font-size を変更することでより簡単に同様の効果を得られる

参考資料

Revision History