zudo-css-wisdom

Type to search...

to open search from anywhere

Display Scale Strategy

CreatedApr 6, 2026UpdatedApr 24, 2026Takeshi Takatsudo

The Problem

Desktop apps built with web technology (Tauri, Electron) render inside a WebView — essentially a browser window without the browser chrome. Users expect the same zoom behavior they get in native apps: Ctrl+scroll or Ctrl+/- to scale the entire UI.

The obvious approach is to use the browser’s native zoom. But in WebView environments, native zoom breaks things:

  • Cursor offset bugs — at non-100% zoom, the cursor position reported by the WebView diverges from the actual screen position. Drag handles, resize panels, and click targets become misaligned.
  • Uncontrolled scaling — native zoom scales everything uniformly, including hairline borders and border-radius values that should stay crisp at any scale.
  • No persistence API — there is no standard way to save and restore the zoom level across sessions via the WebView API.

The result: users zoom in, panels become undraggable, buttons become unclickable, and restarting the app resets the zoom. The feature that was supposed to improve usability makes the app feel broken.

Why Not transform: scale()?

The next instinct is to wrap the app root in transform: scale():

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

This avoids cursor offset bugs but creates new problems:

  • The element’s layout size doesn’t change — a scaled-up app overflows its container
  • Fixed/sticky positioning breaks inside transformed ancestors
  • Text becomes blurry at non-integer scale factors
  • You need to calculate and apply inverse dimensions to the container

Both approaches — native zoom and transform scale — try to scale the UI from the outside. The display scale strategy scales from the inside.

The Solution

Replace browser zoom with a single CSS custom property — --display-scale — and multiply all design tokens by it:

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

Every token that should scale with zoom uses 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));
}

When --display-scale changes from 1 to 1.25, every value recomputes instantly. An 8px gap becomes 10px. A 14px font becomes 17.5px. The toolbar grows from 52px to 65px. The entire UI scales smoothly, proportionally, in a single CSS reflow — no JavaScript layout recalculation needed.

Defining the Scale Steps

Discrete steps work better than continuous scaling. A predefined set prevents awkward intermediate sizes and makes the feature predictable:

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

Zoom in/out cycles through these steps. The user picks a scale in settings or via keyboard shortcuts. The app persists the choice and applies it on startup.

Applying the Scale

The JavaScript side is minimal — just set one CSS property on the root element:

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

To prevent native zoom from interfering, intercept the default zoom gestures:

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

The custom event triggers the scale step logic, persists the new value, and calls applyDisplayScale(). The CSS does the rest.

Token Architecture

The key design decision is what scales and what doesn’t.

Values That Scale

Everything the user perceives as “the UI” should scale:

CategoryExamplesFormula
Spacingpadding, margin, gapcalc(Npx * var(--display-scale))
Font sizesbody text, headings, labelscalc(Npx * var(--display-scale))
Icon sizestoolbar icons, status iconscalc(Npx * var(--display-scale))
Component sizesbuttons, checkboxes, toolbar heightcalc(Npx * var(--display-scale))

Values That Stay Fixed

Some values should remain constant regardless of scale:

CategoryWhy FixedExample
1px hairlinesDividers and separators should stay crisp--spacing-1px: 1px
Border radiusVisual rounding is a style choice, not a size--radius-md: 4px
Border widthThin borders maintain clarity at any scaleborder: 1px solid
Overlay opacityDimming is perception-based, not size-basedrgba(0, 0, 0, 0.6)

The rule of thumb: if the value defines how big something is, it scales. If it defines how it looks (rounding, borders, opacity), it stays fixed.

Demos

Scaled vs Unscaled Tokens

This demo shows the same UI at three different display scales. Notice how spacing, text, and icons scale proportionally while borders and border-radius stay fixed.

Display Scale: 75%, 100%, 125%

Fixed vs Scaled Values

This demo highlights the difference between values that scale and values that stay fixed. The border, border-radius, and divider thickness remain constant — only the content dimensions change.

What Scales vs What Stays Fixed

Tailwind v4 Integration

In a Tailwind v4 project with the tight token strategy, declare base values in @theme and override them with scaled values in :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));
}

The @theme block gives Tailwind the base values it needs for class generation. The :root block overrides them at runtime with the scaled versions. Tailwind classes like p-lg, text-base, and w-icon-md automatically use the scaled values — no changes needed in component code.

One-Off Scaled Values

For component-specific dimensions not in the token set, use calc() directly in arbitrary values:

<!-- 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>

Common Mistakes

  • Scaling border-radius — a 4px corner radius looks right at any scale; scaling it to 5px at 125% adds no value and creates inconsistency when comparing elements at different scales
  • Scaling 1px borders — hairline borders become blurry at non-integer scales; keeping them at 1px maintains visual clarity
  • Forgetting to prevent native zoom — if you don’t intercept Ctrl+wheel and Ctrl+/-, users trigger both native zoom and your custom scale, causing double-scaling
  • Using continuous scale — free-form scale values (e.g., 1.137) create pixel-rounding artifacts; use a predefined step set instead
  • Mixing scaled and unscaled spacing — using gap: 12px instead of gap: var(--spacing-lg) in one component breaks the consistency; once you adopt display scale, all spacing must go through the system
  • Scaling with transform: scale() — this does not change the element’s layout size, breaks fixed/sticky positioning, and causes text blur; it is not a substitute for token-based scaling

When to Use

  • Desktop web apps — Tauri, Electron, or any WebView-based desktop application where users expect zoom functionality
  • Apps with drag/resize interactions — native zoom breaks cursor positioning for drag handles, panel resizers, and canvas interactions
  • Design systems with tight tokens — display scale integrates naturally when every value already flows through CSS custom properties
  • Any app that needs user-configurable UI density — the same mechanism supports accessibility zoom, compact mode, and large-text mode

When NOT to Use

  • Websites viewed in regular browsers — browser zoom works correctly in standard web contexts; adding custom zoom creates a worse experience
  • Static content sites — blogs, documentation, marketing pages have no drag interactions and benefit from native zoom + responsive design
  • Apps that use rem throughout — if all values are rem-based, changing the root font-size achieves a similar effect more simply

References

Revision History