ディスプレイスケール戦略
問題
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-scale が 1 から 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、gap | calc(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つの異なるディスプレイスケールで表示しています。スペーシング、テキスト、アイコンが比例的にスケーリングされる一方、ボーダーやボーダーラディウスは固定のままであることに注目してください。
固定値 vs スケーリング値
このデモは、スケーリングされる値と固定のままの値の違いを示しています。ボーダー、ボーダーラディウス、区切り線の太さは一定のまま — コンテンツの寸法だけが変わります。
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-lg、text-base、w-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を変更することでより簡単に同様の効果を得られる