Skip to content

Scaling an app's UI

@flyze/lib-core-angular 1.0.0-alpha.34· latest

How to give users a UI-scale setting: apply it, persist it, apply it early enough that the page does not flash, and offer it in the UI.

FlyzeUiScaleService is providedIn: 'root' and needs no setup — but it deliberately owns only the clamping and the DOM write. Everything below is the app’s half of the contract. Mechanics: features/ui-scale.

Prerequisite: scaling only does something if the UI is authored in rem. The Flyze component libraries are converting to it; your app’s own stylesheets are not, unless you convert them. See “Convert the app” at the bottom.

export class UiScaleMenuComponent {
private readonly uiScale = inject(FlyzeUiScaleService);
readonly steps = FLYZE_UI_SCALE_STEPS; // [75, 90, 100, 110, 125, 150]
select(percent: number): void {
this.uiScale.setScale(percent);
}
}

That is the whole write path. The browser reflows every rem-authored dimension on its own — no component needs to know the scale changed, and nothing re-renders in Angular.

Offer the steps, not a free slider. FLYZE_UI_SCALE_STEPS is the list the component libraries’ Storybook uiScale toolbar ships, so every value in it has actually been looked at. Six discrete presets can be QA’d; a continuous slider cannot. If you do build a slider, snap it to 5% increments — setScale rounds to whole percent but does not snap.

Values are clamped to 50–200% silently, so it is safe to feed it a number from storage or a URL parameter without validating the range yourself. Non-finite values are rejected with a warning.

The service stores nothing, exactly as FlyzeThemingService stores no color scheme. Where the value lives is a product decision, and FlyzeStorageService is what routes it there — storing-client-state:

const UI_SCALE_STORAGE_KEY = 'ui-scale';
async save(percent: number): Promise<void> {
this.uiScale.setScale(percent);
await this.storage.write(UI_SCALE_STORAGE_KEY, this.uiScale.scale); // the clamped value
}

Read service.scale back rather than the argument, so what you store is what was actually applied.

Leave this key unrouted. The scale describes the device, not the person: it should not follow a user to a machine with a different screen, so it belongs in plain localStorage rather than behind a user-scoped route.

Restoring the scale after Angular has rendered means every load paints at 100% and then jumps. Two places to do it earlier, in increasing order of “no flash at all”:

An app initializer — enough for most apps, and the natural home if you already have a theming initializer:

{
provide: APP_INITIALIZER,
multi: true,
useFactory: () => {
const uiScale = inject(FlyzeUiScaleService);
const storage = inject(FlyzeStorageService);
return async () => {
const stored = await storage.read<number>(UI_SCALE_STORAGE_KEY);
if (stored) uiScale.setScale(stored);
};
}
}

An inline script in index.html — runs before the bundle is even parsed, so there is no window at all in which the wrong size is visible:

<script>
// The UI scale, applied before Angular boots. FlyzeUiScaleService adopts this value on
// construction, so the two cannot disagree. FlyzeStorageService cannot be used here - it
// comes from DI and its contract is async - so read the key it writes, prefix included.
var scale = Number(localStorage.getItem('fyz.v1.ui-scale'));
if (scale >= 50 && scale <= 200 && scale !== 100) {
document.documentElement.style.fontSize = scale + '%';
}
</script>

This is the only case in which an app writes documentElement.style.fontSize itself. The service reads a percentage found there and adopts it, so scale reports 125 rather than 100 — see features/ui-scale. Write a percentage, never a px length: a px value discards the reader’s own browser font-size preference, and the service will warn about it and refuse to adopt it.

Anything that read a pixel measurement into TypeScript is stale after a scale change. Subscribe and recompute:

this.uiScale.scale$
.pipe(takeUntilDestroyed())
.subscribe(() => (this.itemSize = this.optionElement.nativeElement.clientHeight));

Prefer measuring, as above, over computing from the scale. clientHeight is read after the browser resolved rem to px, so it is correct under all four layers that multiply into the baseline. If you must compute, use rootFontSize / resolvedScaleFactornot scale / 100, which ignores the reader’s own browser font-size setting.

The library scaling the components does nothing for the app’s own px. The full checklist is “Adopting the scaling system in a consumer” in storybook-component-lib’s docs/proposals/rem-based-ui-scaling.md; the four things that bite hardest:

  1. Every --fyz-sb-*-auto-* value your app writes must be rem. A px one pins that whole subtree: it looks right at 100% and simply stops growing. This is the single most common mistake.
  2. px arithmetic in TypeScript — virtual-scroll itemSize, hardcoded row heights, any constant that mirrors a CSS value. Measure instead of assuming.
  3. px @media and @container queries do not respond to the root font size. Convert to em where the layout should track the scale; leave real device-width queries alone.
  4. Persisted px state — stored panel widths, saved layouts — survives the conversion and masks your new defaults. Bump the storage version.

Keep in px, deliberately: 1px borders and focus rings, anything sized to line up with an Angular Material internal, and third-party chrome that has its own metrics.