t interface ConversionContext {
rootFontSize: number;
parentFontSize: number;
viewportWidth: number;
viewportHeight: number;
}
export class BrowserContextProvider {
private cache: ConversionContext | null = null;
private listeners: Set<() => void> = new Set();
resolve(): ConversionContext {
if (this.cache) return this.cache;
const root = document.documentElement;
const parent = document.body;
const rootSize = parseFloat(getComputedStyle(root).fontSize) || 16;
const parentSize = parseFloat(getComputedStyle(parent).fontSize) || rootSize;
this.cache = {
rootFontSize: rootSize,
parentFontSize: parentSize,
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight,
};
return this.cache;
}
invalidate(): void {
this.cache = null;
this.listeners.forEach(cb => cb());
}
onChange(callback: () => void): () => void {
this.listeners.add(callback);
return () => this.listeners.delete(callback);
}
}
### Step 2: Implement Canonical Resolution
All conversions route through pixels. This eliminates redundant math and centralizes precision handling.
```typescript
export type CssUnit = 'px' | 'rem' | 'em' | 'vw' | 'vh' | 'pt' | 'pc' | 'cm' | 'mm' | 'in';
export class UnitResolver {
private static readonly PX_PER_PT = 4 / 3;
private static readonly PX_PER_PC = 16;
private static readonly PX_PER_CM = 96 / 2.54;
private static readonly PX_PER_MM = 96 / 25.4;
private static readonly PX_PER_IN = 96;
constructor(private context: ConversionContext) {}
toCanonical(value: number, unit: CssUnit): number {
if (!Number.isFinite(value)) return 0;
switch (unit) {
case 'px': return value;
case 'rem': return value * this.context.rootFontSize;
case 'em': return value * this.context.parentFontSize;
case 'vw': return (value * this.context.viewportWidth) / 100;
case 'vh': return (value * this.context.viewportHeight) / 100;
case 'pt': return value * UnitResolver.PX_PER_PT;
case 'pc': return value * UnitResolver.PX_PER_PC;
case 'cm': return value * UnitResolver.PX_PER_CM;
case 'mm': return value * UnitResolver.PX_PER_MM;
case 'in': return value * UnitResolver.PX_PER_IN;
default: throw new Error(`Unsupported unit: ${unit}`);
}
}
fromCanonical(pxValue: number, targetUnit: CssUnit): number {
if (!Number.isFinite(pxValue)) return 0;
switch (targetUnit) {
case 'px': return pxValue;
case 'rem': return pxValue / this.context.rootFontSize;
case 'em': return pxValue / this.context.parentFontSize;
case 'vw': return (pxValue / this.context.viewportWidth) * 100;
case 'vh': return (pxValue / this.context.viewportHeight) * 100;
case 'pt': return pxValue / UnitResolver.PX_PER_PT;
case 'pc': return pxValue / UnitResolver.PX_PER_PC;
case 'cm': return pxValue / UnitResolver.PX_PER_CM;
case 'mm': return pxValue / UnitResolver.PX_PER_MM;
case 'in': return pxValue / UnitResolver.PX_PER_IN;
default: throw new Error(`Unsupported unit: ${targetUnit}`);
}
}
convert(value: number, sourceUnit: CssUnit, targetUnit: CssUnit): number {
const canonical = this.toCanonical(value, sourceUnit);
return this.fromCanonical(canonical, targetUnit);
}
}
Step 3: Orchestrate Live Conversion
The engine ties context resolution to conversion requests, ensuring stale values are never served.
export class CssUnitEngine {
private resolver: UnitResolver;
constructor(private contextProvider: BrowserContextProvider) {
this.resolver = new UnitResolver(this.contextProvider.resolve());
}
refreshContext(): void {
this.contextProvider.invalidate();
this.resolver = new UnitResolver(this.contextProvider.resolve());
}
convertAll(sourceValue: number, sourceUnit: CssUnit): Record<CssUnit, number> {
const units: CssUnit[] = ['px', 'rem', 'em', 'vw', 'vh', 'pt', 'pc', 'cm', 'mm', 'in'];
const results: Partial<Record<CssUnit, number>> = {};
units.forEach(target => {
if (target !== sourceUnit) {
results[target] = this.resolver.convert(sourceValue, sourceUnit, target);
}
});
return results as Record<CssUnit, number>;
}
}
Architecture Decisions & Rationale
- Canonical Intermediate (px): Routing through pixels eliminates the need for a full conversion matrix. Adding new units only requires two methods (
toCanonical/fromCanonical) instead of updating every pairwise conversion.
- Context Invalidation Pattern: Viewport and font sizes change independently. Caching context and explicitly invalidating it prevents unnecessary DOM reads while guaranteeing fresh values on demand.
- Static Constants for Physical Units:
pt, pc, cm, mm, in rely on the CSS 96 DPI baseline. Hardcoding these ratios avoids runtime math overhead and aligns with W3C specifications.
- TypeScript Strictness: Union types for
CssUnit prevent typos and enable exhaustive switch statements. The compiler catches missing unit handlers at build time.
- Separation of Concerns: Context resolution, mathematical conversion, and orchestration are isolated. This enables unit testing, SSR mocking, and framework-agnostic integration.
Pitfall Guide
1. Hardcoding Root Font Size to 16px
Explanation: Browsers default to 16px, but users can override this in accessibility settings or via user stylesheets. Hardcoding breaks proportional scaling.
Fix: Always read getComputedStyle(document.documentElement).fontSize at runtime. Provide a fallback only for SSR or headless environments.
2. Ignoring em Inheritance Chains
Explanation: em resolves against the nearest parent with an explicit font-size, not the root. Nested components compound values unexpectedly.
Fix: Resolve parentFontSize dynamically per component scope. Pass explicit context boundaries when converting within deeply nested layouts.
3. Treating Viewport Units as Static
Explanation: vw and vh ignore browser UI chrome on mobile and shift during orientation changes or virtual keyboard display. Static conversion causes overflow or clipping.
Fix: Bind context invalidation to resize and orientationchange events. Debounce updates to avoid layout thrashing.
4. Precision Drift in Repeated Conversions
Explanation: Floating-point arithmetic accumulates rounding errors when converting back and forth between units.
Fix: Round canonical values to 4 decimal places before distribution. Use Number.EPSILON comparisons for equality checks in tests.
5. Converting During Layout Thrashing
Explanation: Reading computed styles triggers forced reflows. Calling conversion functions inside animation loops or scroll handlers degrades performance.
Fix: Batch context reads. Cache results and invalidate only on meaningful viewport/font changes. Use requestAnimationFrame for UI updates.
6. Assuming Physical Units Match Screen DPI
Explanation: cm, mm, and in assume 96 DPI. High-DPI displays and print media render these differently, causing scale mismatches.
Fix: Document DPI assumptions clearly. Provide a dpiOverride configuration for print-specific workflows or high-density testing.
Explanation: CSS @media rules can change root font sizes or viewport constraints. Runtime converters unaware of active media queries produce stale values.
Fix: Listen to matchMedia changes. Invalidate context when relevant media queries toggle. Integrate with CSS-in-JS or design token systems that respond to breakpoints.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Static design tokens | Build-time Sass/Less conversion | Zero runtime overhead, predictable output | Low dev cost, high maintenance on theme changes |
| Dynamic theming | Runtime context-aware engine | Adapts to user preferences and viewport shifts | Moderate dev cost, negligible runtime cost |
| Print/media workflows | DPI-aware physical unit resolver | Aligns with W3C print specifications | Low dev cost, requires explicit DPI configuration |
| Framework integration | Context provider abstraction | Decouples conversion logic from React/Vue/Angular | Moderate initial setup, high long-term flexibility |
Configuration Template
export interface EngineConfig {
precision: number;
debounceMs: number;
fallbackRootSize: number;
fallbackParentSize: number;
enableMediaQuerySync: boolean;
}
export const DEFAULT_CONFIG: EngineConfig = {
precision: 4,
debounceMs: 150,
fallbackRootSize: 16,
fallbackParentSize: 16,
enableMediaQuerySync: true,
};
export function createEngine(config: Partial<EngineConfig> = {}): CssUnitEngine {
const merged = { ...DEFAULT_CONFIG, ...config };
const provider = new BrowserContextProvider();
if (merged.enableMediaQuerySync) {
window.matchMedia('(prefers-reduced-motion: no-preference)').addEventListener('change', () => {
provider.invalidate();
});
}
const engine = new CssUnitEngine(provider);
let timeoutId: number;
const debouncedRefresh = () => {
clearTimeout(timeoutId);
timeoutId = window.setTimeout(() => engine.refreshContext(), merged.debounceMs);
};
window.addEventListener('resize', debouncedRefresh);
window.addEventListener('orientationchange', debouncedRefresh);
return engine;
}
Quick Start Guide
- Initialize the engine: Import
createEngine and call it with optional configuration. The engine automatically binds viewport and media query listeners.
- Resolve context: Call
engine.refreshContext() when theme changes or accessibility overrides occur. The engine caches values and invalidates on demand.
- Convert units: Use
engine.convertAll(value, 'rem') to get a complete mapping of all supported units. Results are rounded to the configured precision.
- Integrate with UI: Bind conversion outputs to form inputs, design token displays, or layout calculators. Debounce user input to avoid excessive resolution calls.
- Test edge cases: Verify behavior with root font size overrides, nested
em inheritance, and mobile viewport chrome. Use mock context providers for unit tests.