1.5rem, 24px, 18pt and 1.6667vw can all describe the exact same physical length on a page. A unit only decides what "1" is measured against — some are absolute, the rest are relative to something you control. So my first instinct that a converter needs an N×N table of unit pairs was wrong: with one canonical unit you never write those pairs. Pick pixels as the source of truth, give every unit a single number — "how many px is 1 of me?" — and every conversion is multiply to px, divide back out. One function and two one-liners. Here's the whole engine.
Each unit answers one question: how many px is 1 of me?
That number is the unit's factor. Absolute units have a fixed one; relative units read theirs from context you can change — rem from the root font-size, em from the parent, vw from the viewport, % from a base length.
const PX_PER_PT = 96 / 72; // 1in = 96px = 72pt -> 1pt = 4/3 px
const ctx = { root:16, parent:16, vw:1440, vh:900, pctBase:640 };
function pxPerUnit(u){
switch (u){
case "px": return 1; case "pt": return PX_PER_PT;
case "rem": return ctx.root; case "em": return ctx.parent;
case "%": return ctx.pctBase/100;
case "vw": return ctx.vw/100; case "vh": return ctx.vh/100;
}
}
Two one-liners: to pixels and back
Every conversion is those two, composed. There is no remToPt, no lookup table — just a trip through the canonical unit.
const toPx = (value, unit) => value * pxPerUnit(unit);
const fromPx = (px, unit) => px / pxPerUnit(unit);
So 1.5rem → 24px → 18pt is fromPx(toPx(1.5, "rem"), "pt"), and adding a new unit means adding one case, not one column per existing unit. With seven units that's the difference between writing 7 factors and writing 49 conversion pairs — the canonical unit collapses the whole N×N table down to N numbers.
The 1pt = 96/72 px that trips everyone
CSS pins the relationship 1in = 96px = 72pt, which is exactly why one point is 96/72 = 4/3 ≈ 1.333px, not 1px. Assuming 1pt = 1px is one of the most common unit mistakes. And a CSS px isn't a hardware pixel either — it's a fixed reference pixel that the device maps to real dots via devicePixelRatio.
One px truth, seven live views
The UI has no special logic. There's exactly one source of truth — currentPx — and every field is a view of it. Type in a field and it's parsed in its own base; on success it becomes the new truth and every other field re-renders.
function onEdit(field){
const r = parseLen(field.input.value);
if (!r.ok){ markInvalid(field, r.error); return; } // leave others untouched
currentPx = toPx(r.value, field.unit);
propagate(field.unit); // rewrite the rest
}
propagate skips the active field so the caret never jumps, and rewrites the other six with fromPx(currentPx, unit). An invalid entry just marks that one field red and leaves the value alone — an empty field is "no value," not an error. The parser is tolerant on the way in too: it lowercases, accepts a trailing unit like 1.5rem, and strips it before reading the number.
Change the context, keep the pixels
em vs rem is the classic trap: rem is always the root font-size (one stable anchor), while em is the current element's size, so it compounds when nested. The converter makes that explicit — five inputs expose root, parent, viewport width/height and the % base. Change any of them and I hold currentPx fixed and re-derive every relative unit, because "how many px is 2rem?" genuinely has no answer until you fix the root. Rounding is display-only (Math.round(n * 1e4) / 1e4); the exact value lives in currentPx, so re-reading a field never drifts.
The lesson underneath is the same one I keep relearning: find the single source of truth, give every other view a pure function of it, and the "N×N problem" quietly disappears.
Type in any field and watch the rest re-spell the same length:
https://dev48v.infy.uk/solve/day48-css-unit-converter.html
Top comments (0)