Heygrady (there's a new blog)

I don't get It.

Length Unit Conversion in JavaScript

Permalink

When creating JavaScript libraries, it's difficult to support the full range of units and most developers settle on just supporting pixels. CSS supports several units for length as explained in this MDN article but supporting them with JavaScript can be tricky. This post explains the basic technique for converting units and the inherent browser bugs that need to be overcome. This post also introduces a new Units library I've created and posted to GitHub.

If you have some weird reason to handle arbitrary units in your JavaScript library, the code demonstrated below should offer the most complete solution possible in the smallest possible package.

What We're Trying to Do

The basic goal is to create a small library that will make it possible to convert any valid value to pixels. This makes it possible to do further calculations using that pixel value.

example.js
// find our target element
var elem = document.getElementById('#some-elem');
// find an arbitrary value in pixels
Length.toPx(elem, '10%'); // depends on parent width
Length.toPx(elem, '96px'); // 96px
Length.toPx(elem, '25.4mm'); // 96px
Length.toPx(elem, '2.54cm'); // 96px
Length.toPx(elem, '1in'); // 96px
Length.toPx(elem, '72pt'); // 96px
Length.toPx(elem, '6pc'); // 96px
Length.toPx(elem, '25.4mozmm'); // around 96px
Length.toPx(elem, '10vh'); // depends on viewport
Length.toPx(elem, '10vw'); // depends on viewport
Length.toPx(elem, '10vm'); // depends on viewport
Length.toPx(elem, '6em'); // 96px with default font styles
Length.toPx(elem, '6rem'); // 96px with default font styles
Length.toPx(elem, '13.4ex'); // around 96px with default font styles
Length.toPx(elem, '12ch'); // around 96px with default font styles

The Trouble with Units

Converting between length units is not exactly straight-forward because each unit can have different pixel value based on a variety of uncontrollable conditions. Factors such as font properties (em, ch, ex), the OS settings for your display DPI (in, mm, cm, pt, pc) or even the dimensions of the browser (vh, vw, vm) can change the pixel value of a unit. Because there is no direct conversion function available to JavaScript it's necessary to rely on manipulating the style attribute on the element in-order-to trick the browser into doing the conversion for you.

Absolute Units

The easiest units to convert are the absolute units. These are units that presumably represent real, fixed lengths (but actually don't). In the the real world there is no such thing as a relative inch — an inch is always an inch regardless of the settings on your monitor. On computers it's a little different; the absolute lengths are based on the DPI setting. On the vast majority of computers this is set to 96, which means that 1 inch usually equals 96 pixels. However, this can sometimes change: it's not entirely uncommon for the DPI to be set to 120 which would make 1 inch equal to 120 pixels. This is also already different on mobile devices like the iPhone 4 or newer (although the iPhone currently lies and claims to be 163DPI).

Because DPI is the deciding factor in absolute lengths, it's useful to define the conversions relative to the inch; this makes all of the other conversions easier (thankfully a pixel is always a pixel — except on iPhones). If you know the pixels in an inch, you can easily know the pixels in a pica.

inches
relative to OS DPI, usually 96px
millimeters
1mm = 1in/25.4
centimeters
1cm = 1in/2.54
point
1pt = 1in/72
picas
1pc = 1in/6
Mozilla millimeters
1mozmm = whatever Mozilla feels like

Font-relative Units

As the name might suggest, font-relative units are dependent on the font settings of the element itself. The most commonly used is the em unit. One em equals the font-size of the element (except in the case of the font-size property where it means the font-size of the parent element). The ex and ch units are rarely used and equal the height of lower-case "x" and the width of a "0" respectively. New to CSS3 — and much more useful than ex — is the rem unit which equals the em of the html element. Converting rem and em to pixels is relatively straight-forward — all you really need is the element's (or the parent or html element's) font-size — but ex and ch require using the style attribute as we'll see later.

Viewport-relative Units

Presumably useful for flexible layouts, these units are calculated relative to the browser dimensions and are currently only supported in IE9 and Opera. The three units are vh (viewport height/100) vw (viewport width/100) and vm (the smaller of vh or vw). These units change every time the browser is resized.

Percentage Lengths

Perhaps the most complex unit to convert is percentage. The relative length for a percentage can differ based on the CSS property but it is usually based on the width of the parent element. For instance, a width of 50% means 50% of the width of the parent element. For things like padding and margin it can get confusing because these also always mean the width of the parent; counter-intuitively, padding-top of 50% means 50% of the width of the parent element as well, not the height. The best way to figure out what percentage is referring to is to look the property up on the MDN website.

Converting Units

As mentioned above, converting units can be done reliably* by setting the value using the style property and reading it back using getComputedStyle**. The getComputedStyle function always*** returns values using pixels.

*sometimes **in supported browsers ***usually

Simple.jsGist page
(function(window, document, undefined){
"use strict";
var runit = /^(-?[\d+\.\-]+)([a-z]+|%)$/i;
// convert a value to pixels
function toPx(elem, value, prop) {
// use width as the default property, or specify your own
prop = prop || 'width';
// begin "the awesome hack by Dean Edwards"
// @see http://erik.eae.net/archives/2007/07/27/18.54.15/#comment-102291
// remember the current style
var style = elem.style,
inlineValue = style[prop],
ret;
// set the style on the target element
try {
style[prop] = value;
} catch(e) {
// IE 8 and below throw an exception when setting unsupported units
return 0;
}
// read the computed value
// if style is nothing we probably set an unsupported unit
ret = !style[prop] ? 0 : currCSS(elem, prop);
// reset the style back to what it was or blank it out
style[prop] = inlineValue !== undefined ? inlineValue : null;
// remove the unit and return a number
return parseFloat(ret);
}
// return the computed value of a CSS property
function currCSS(elem, prop) {
var value,
pixel,
getComputedStyle = document.defaultView && document.defaultView.getComputedStyle,
unit;
if (getComputedStyle) {
// FireFox, Chrome/Safari, Opera and IE9+
value = getComputedStyle(elem)[prop];
} else if (pixel = elem.style['pixel' + prop.charAt(0).toUpperCase() + prop.slice(1)]) {
// IE and Opera support pixel shortcuts for top, bottom, left, right, height, width
// Chrome supports pixel shortcuts for those properties only when an absolute unit is used
value = pixel + 'px';
} else {
// IE 8 and below won't convert units for us
value = elem.currentStyle[prop];
// check the unit
unit = (value.match(runit)||[])[2];
if (unit && unit !== 'px') {
// try to convert using a prop that will return pixels
// this will be accurate for everything (except font-size and some percentages)
value = toPx(elem, value) + 'px';
}
}
return value;
}
// expose the conversion function to the window object
window.Length = {
toPx: toPx
};
}(this, this.document));

How Conversion Works

The code above is the bare minimum necessary to convert pixel units but it has a few problems that will be discussed later. But first, let's look at what we're doing in the code. In the the toPx function, we can convert any unit to pixels by assigning the value to the elements style property, reading that value back as using the curCSS function (which uses getComputedStyle) and then changing the element's style back.

This method of checking a property, changing it, measuring it and changing it back is commonly referred to as the "awesome hack by Dean Edwards". (The Dean Edwards hack relies on the proprietary runtimeStyle property because his comment was originally meant to address an IE-specific issue).

Using getComputedStyle isn't supported in IE 8 and below; they use currentStyle instead, and it works slightly differently. Specifically, currentStyle won't convert anything to pixels; it just returns raw values (known as the specified value). For instance, if the fontSize is set to 10mm, IE8 and below will return it as 10mm instead of 37.795276px. This problem is discussed in more detail below.

In the example above the default property is set to width ("the awesome hack by Dean Edwards" uses the left property) because in non-IE browsers left cannot be set on a position: static element. Width will reliably return pixel results even when the element is display: inline in any browser. It should be noted that in IE 8 and below, left can be reliably set and retrieved on any element regardless of its position property.

Problems with this Method

If browsers were perfect, the code above would work perfectly. However there are a number of bugs that need to be accounted for. Surprisingly, most of these bugs occur in WebKit, not IE.

WebKit and Computed Value and Used Value

WebKit will not convert percentages when they are applied to margin, top, bottom, left, or right. There is a bug report for the position properties but it isn't clear that the WebKit team intends to fix it. The MDN page for getComputedStyle explains that the function should report the used value and not the computed value (which is confusing to say the least). The primary difference between the two is that a computed value can sometimes be a percentage (in the case of margin, top, bottom, left, right, text-indent and others). WebKit is apparently using the computed value in getComputedStyle which the bug tracker suggests is due to a disagreement about the spec. All other browsers are using the used value.

A ticket in the jQuery bug tracker proposes a fix for WebKet returning a percentage for margin. However, as noted above, this bug also applies to positioning properties as well (and text-indent). The commit for fixing percentage margins in jQuery specifically addresses margins but it could easily be extended to support the other affected properties.

IE 8 and Below and Specified Value

IE8 and below don't support the getComputedStyle function so the non-standard currentStyle property must be used. Unlike getComputedStyle, which is supposed to return the used value, currentStyle returns the specified value (the MSDN manual calls this the "cascaded value"). The specified value is the value that should be assigned to the property but before it has been converted to an absolute unit. This is more useful than the style property, which only exposes the inline values for the element; currentStyle will return values specified in CSS as well as inherited values.

Thankfully IE 8 and below support a few non-standard properties that always return pixel values. The most commonly used property is pixelLeft (this is used in "the awesome hack"), however pixelWidth and pixelHeight work just as well. Opera also supports the non-standard pixel properties. WebKit supports them only for absolute units and Firefox doesn't support them at all. Interestingly, IE9 doesn't support them anymore because they were deprecated in an earlier version of IE.

Every unit (except percentages) is the same pixel value on an element no matter which property it is applied to (except fontSize). For instance, a marginLeft of 1em is the same length as a lineHeight of 1em. Because of this fact, it is reasonably safe to use the special pixel values to convert units in IE 8 and below.

There is no direct way to convert the fontSize because it could be relative to the parent elements fontSize in the case of em and percentages. A jQuery bug about differing values for fontSize points to a fix where using a left of 1em will always equal the element's current fontSize.

What runtimeStyle Does

As noted above, "the awesome hack by Dean Edwards" uses runtimeStyle for pixel conversions in IE. This IE-specific property is higher than the style property in the CSS cascade which means that a runtimeStyle value supersedes a style value. By copying the currentStyle value to the runtimeStyle, "the awesome hack" avoids any potential for a FOUC. This works because changes to the style property aren't rendered in the browser when a runtimeStyle is set and the special pixel properties don't pay attention to the runtimeStyle (because they belong to the style object), so they can still be used to return the pixel value of the style property. The runtimeStyle is only available in IE and isn't useful for unit conversion beyond trying to fix FOUC issues if they appear in IE 8 or below.

Fixing the Bugs

Now that we understand the problems, we need to set about fixing them! The code below contains a patched curCSS function.

Patched.jsGist page
(function(window, document, undefined){
"use strict";
var testElem,
docElement = document.documentElement,
defaultView = document.defaultView,
getComputedStyle = defaultView && defaultView.getComputedStyle,
computedValueBug,
runit = /^(-?[\d+\.\-]+)([a-z]+|%)$/i;
// test for the WebKit getComputedStyle bug
// @see http://bugs.jquery.com/ticket/10639
if (getComputedStyle) {
// create a test element
testElem = document.createElement('test');
// add the test element to the dom
docElement.appendChild(testElem);
// add a percentage margin and measure it
testElem.style.marginTop = '1%';
computedValueBug = getComputedStyle(testElem).marginTop === '1%';
// remove the test element from the DOM and delete it
docElement.removeChild(testElem);
testElem = undefined;
}
// convert a value to pixels
function toPx(elem, value, prop) {
// use width as the default property, or specify your own
prop = prop || 'width';
// begin "the awesome hack by Dean Edwards"
// @see http://erik.eae.net/archives/2007/07/27/18.54.15/#comment-102291
// remember the current style
var style = elem.style,
inlineValue = style[prop],
ret;
// set the style on the target element
try {
style[prop] = value;
} catch(e) {
// IE 8 and below throw an exception when setting unsupported units
return 0;
}
// read the computed value
// if style is nothing we probably set an unsupported unit
ret = !style[prop] ? 0 : curCSS(elem, prop);
// reset the style back to what it was or blank it out
style[prop] = inlineValue !== undefined ? inlineValue : null;
// remove the unit and return a number
return parseFloat(ret);
}
// return the computed value of a CSS property
function curCSS(elem, prop) {
var value,
pixel,
unit,
rvpos = /^top|bottom/,
outerProp = ["paddingTop", "paddingBottom", "borderTop", "borderBottom"],
innerHeight,
parent,
i = 4; // outerProp.length
if (getComputedStyle) {
// FireFox, Chrome/Safari, Opera and IE9+
value = getComputedStyle(elem)[prop];
} else if (pixel = elem.style['pixel' + prop.charAt(0).toUpperCase() + prop.slice(1)]) {
// IE and Opera support pixel shortcuts for top, bottom, left, right, height, width
// WebKit supports pixel shortcuts only when an absolute unit is used
value = pixel + 'px';
} else if (prop === 'fontSize') {
// correct IE issues with font-size
// @see http://bugs.jquery.com/ticket/760
value = toPx(elem, '1em', 'left') + 'px';
} else {
// IE 8 and below return the specified style
value = elem.currentStyle[prop];
}
// check the unit
unit = (value.match(runit)||[])[2];
if (unit === '%' && computedValueBug) {
// WebKit won't convert percentages for top, bottom, left, right, margin and text-indent
if (rvpos.test(prop)) {
// Top and bottom require measuring the innerHeight of the parent.
innerHeight = (parent = elem.parentNode || elem).offsetHeight;
while (i--) {
innerHeight -= parseFloat(curCSS(parent, outerProp[i]));
}
value = parseFloat(value) / 100 * innerHeight + 'px';
} else {
// This fixes margin, left, right and text-indent
// @see https://bugs.webkit.org/show_bug.cgi?id=29084
// @see http://bugs.jquery.com/ticket/10639
value = toPx(elem, value);
}
} else if ((value === 'auto' || (unit && unit !== 'px')) && getComputedStyle) {
// WebKit and Opera will return auto in some cases
// Firefox will pass back an unaltered value when it can't be set, like top on a static element
value = 0;
} else if (unit && unit !== 'px' && !getComputedStyle) {
// IE 8 and below won't convert units for us
// try to convert using a prop that will return pixels
// this will be accurate for everything (except font-size and some percentages)
value = toPx(elem, value) + 'px';
}
return value;
}
// expose the conversion function to the window object
window.Length = {
toPx: toPx
};
}(this, this.document));

The code above adds several fixes to the curCSS function to correct for the issues mentioned above. It also adds a check to see if the browser is incorrectly returning the current value instead of the used value.

Changes to the curCSS Function

  • Correct for IE being unable to reconcile the fontSize property by converting the left of 1em to pixels.
  • Check for percentage units in WebKit.
    • For top and bottom, compute the parent element's inner height and return the percentage.
    • For other properties (margin, left, right, text-indent) convert to pixels using the width property.
  • Correct for WebKit and Opera returning the computed value "auto" in cases top on an element that is position: static.
  • Correct for Firefox returning the specified value when it can't be set in cases like top on an element that is position: static.

Finishing the Job

Now that we've fixed all of the bugs with curCSS, it's time to make toPx a little smarter. For instance, absolute units will never change under any circumstances once they've been calculated. This means that once you know how many pixels are in an inch, converting an inch unit requires only simple algebra and you can skip "the awesome hack" altogether. The same goes for em and rem: once you know the correct fontSize in pixels, a little algebra finishes the job, no "the awesome hack" necessary. This actually covers the majority of cases for units and leaves only percentages as the primary need for mucking with the element's style property.

Final.jsGist page
(function(window, document, undefined){
"use strict";
// create a test element
var testElem = document.createElement('test'),
docElement = document.documentElement,
defaultView = document.defaultView,
getComputedStyle = defaultView && defaultView.getComputedStyle,
computedValueBug,
runit = /^(-?[\d+\.\-]+)([a-z]+|%)$/i,
convert = {},
conversions = [1/25.4, 1/2.54, 1/72, 1/6],
units = ['mm', 'cm', 'pt', 'pc', 'in', 'mozmm'],
i = 6; // units.length
// add the test element to the dom
docElement.appendChild(testElem);
// test for the WebKit getComputedStyle bug
// @see http://bugs.jquery.com/ticket/10639
if (getComputedStyle) {
// add a percentage margin and measure it
testElem.style.marginTop = '1%';
computedValueBug = getComputedStyle(testElem).marginTop === '1%';
}
// pre-calculate absolute unit conversions
while(i--) {
convert[units[i] + "toPx"] = conversions[i] ? conversions[i] * convert.inToPx : toPx(testElem, '1' + units[i]);
}
// remove the test element from the DOM and delete it
docElement.removeChild(testElem);
testElem = undefined;
// convert a value to pixels
function toPx(elem, value, prop, force) {
// use width as the default property, or specify your own
prop = prop || 'width';
var style,
inlineValue,
ret,
unit = (value.match(runit)||[])[2],
conversion = unit === 'px' ? 1 : convert[unit + 'toPx'],
rem = /r?em/i;
if (conversion || rem.test(unit) && !force) {
// calculate known conversions immediately
// find the correct element for absolute units or rem or fontSize + em or em
elem = conversion ? elem : unit === 'rem' ? docElement : prop === 'fontSize' ? elem.parentNode || elem : elem;
// use the pre-calculated conversion or fontSize of the element for rem and em
conversion = conversion || parseFloat(curCSS(elem, 'fontSize'));
// multiply the value by the conversion
ret = parseFloat(value) * conversion;
} else {
// begin "the awesome hack by Dean Edwards"
// @see http://erik.eae.net/archives/2007/07/27/18.54.15/#comment-102291
// remember the current style
style = elem.style;
inlineValue = style[prop];
// set the style on the target element
try {
style[prop] = value;
} catch(e) {
// IE 8 and below throw an exception when setting unsupported units
return 0;
}
// read the computed value
// if style is nothing we probably set an unsupported unit
ret = !style[prop] ? 0 : parseFloat(curCSS(elem, prop));
// reset the style back to what it was or blank it out
style[prop] = inlineValue !== undefined ? inlineValue : null;
}
// return a number
return ret;
}
// return the computed value of a CSS property
function curCSS(elem, prop) {
var value,
pixel,
unit,
rvpos = /^top|bottom/,
outerProp = ["paddingTop", "paddingBottom", "borderTop", "borderBottom"],
innerHeight,
parent,
i = 4; // outerProp.length
if (getComputedStyle) {
// FireFox, Chrome/Safari, Opera and IE9+
value = getComputedStyle(elem)[prop];
} else if (pixel = elem.style['pixel' + prop.charAt(0).toUpperCase() + prop.slice(1)]) {
// IE and Opera support pixel shortcuts for top, bottom, left, right, height, width
// WebKit supports pixel shortcuts only when an absolute unit is used
value = pixel + 'px';
} else if (prop === 'fontSize') {
// correct IE issues with font-size
// @see http://bugs.jquery.com/ticket/760
value = toPx(elem, '1em', 'left', 1) + 'px';
} else {
// IE 8 and below return the specified style
value = elem.currentStyle[prop];
}
// check the unit
unit = (value.match(runit)||[])[2];
if (unit === '%' && computedValueBug) {
// WebKit won't convert percentages for top, bottom, left, right, margin and text-indent
if (rvpos.test(prop)) {
// Top and bottom require measuring the innerHeight of the parent.
innerHeight = (parent = elem.parentNode || elem).offsetHeight;
while (i--) {
innerHeight -= parseFloat(curCSS(parent, outerProp[i]));
}
value = parseFloat(value) / 100 * innerHeight + 'px';
} else {
// This fixes margin, left, right and text-indent
// @see https://bugs.webkit.org/show_bug.cgi?id=29084
// @see http://bugs.jquery.com/ticket/10639
value = toPx(elem, value);
}
} else if ((value === 'auto' || (unit && unit !== 'px')) && getComputedStyle) {
// WebKit and Opera will return auto in some cases
// Firefox will pass back an unaltered value when it can't be set, like top on a static element
value = 0;
} else if (unit && unit !== 'px' && !getComputedStyle) {
// IE 8 and below won't convert units for us
// try to convert using a prop that will return pixels
// this will be accurate for everything (except font-size and some percentages)
value = toPx(elem, value) + 'px';
}
return value;
}
// expose the conversion function to the window object
window.Length = {
toPx: toPx
};
}(this, this.document));

What Just Happened?

The final code above makes a few changes to the toPx function as well as pre-computing all of the conversions for the absolute units.

  • Pre-calculate the absolute units by converting mozmm and in to pixels using the toPx function and then using using the pixels-per-inch value to store the other conversions.
  • Look for a pre-calculated conversion or a rem or em unit to convert the value to pixels immediately.
  • For rem and em, choose the correct element to get the fontSize from and get the fontSize in pixels using the curCSS function and use that as the conversion.

What Is This Good For?

Nothing? It depends.

Anyone familiar with jQuery knows that $.css will return the correct value for a property on an element but jQuery doesn't directly expose a method for converting units arbitrarily on an element. jQuery will convert units when calculating the start and end values for an animation (and it uses a version of "the awesome hack" to do it) but the conversion performed is limited in scope.

It's not possible to rely on jQuery (1.7.1 as of this writing) to handle unit conversion because it doesn't expose those methods to the API. Further, the optimizations for converting absolute, rem and em units without using "the awesome hack" should provide some speed improvements on any code that needs to rely on conversions. If you are writing a plugin, particularly a polyfill that requires some calculations to be done on a CSS property, properly converting units will allow users to specify values using the units that make sense to them instead of forcing everything to be specified in pixels.

JavaScript plug-ins don't usually do full-featured unit conversions because the code is typically too large to justify inclusion. However, the Final.js file above will minify to around 1.24KB (774 bytes gzipped) using YUI Compressor, 1.2KB (758 bytes gzipped) using uglify.js or 1.16KB (745 bytes gzipped) using Google Closure Compiler. The Google Closure Compiler minified version can be downloaded here.

The GitHub repo for Length and Angle units will hold the latest code and also contains a straight-forward Angle conversion library.

Comments