When we set out to build finger-friendly controls for iOS devices in Basecamp, two major constraints informed our design.

In Basecamp on a PC, when you hover over a to-do, milestone, or file, you’ll see edit and delete controls. But as has been covered here and elsewhere on the web, there’s no way to hover on a touch device. So our solution to this first constraint is to show the controls when you tap instead of when you hover.

The second constraint is that the controls must be finger-friendly. That is, they should be sized such that they’re always big enough to operate with a thumb, but never too big to fit on the screen.

Our first attempt at these controls turned out to be too small when zoomed out…

…and too big when zoomed in.

There’s a sweet spot where the controls are just the right size for a finger.

We could use Mobile Safari’s <meta name=viewport> tag to fix Basecamp’s page width to this sweet spot, so our controls would always be the right size. But then we’d need to redesign everything else in the app around the new width.

The solution

Instead, we developed a JavaScript technique for dynamically scaling elements based on the current zoom factor. The technique provides a magic class name device_scale that you can apply to any element you want to remain the same size regardless of zoom.

Here’s how it works. The script creates a stylesheet and installs event listeners to watch for changes in the page’s dimensions as a result of panning, zooming and rotation. When the dimensions change, the script recalculates the ratio of device width (number of actual pixels on the screen) to page width (number of virtual pixels on the page) and updates the stylesheet accordingly:

.device_scale { -webkit-transform: scale(/* <ratio here> */) }

To use this technique in your own applications, just include the script and sprinkle in class=”device_scale” as appropriate.

Now Basecamp’s controls are sized the same regardless of how far you’ve zoomed in or out, and we didn’t need to redesign the entire application to account for it.

Here’s the script:

(function() {
  var hasTouchSupport = "createTouch" in document;
  if (!hasTouchSupport) return;

  var headElement  = document.getElementsByTagName("head")[0];
  var styleElement = document.createElement("style");

  styleElement.setAttribute("type", "text/css");
  headElement.appendChild(styleElement);

  var stylesheet = styleElement.sheet;

  window.addEventListener("scroll", updateDeviceScaleStyle, false);
  window.addEventListener("resize", updateDeviceScaleStyle, false);
  window.addEventListener("load",   updateDeviceScaleStyle, false);
  updateDeviceScaleStyle();

  function updateDeviceScaleStyle() {
    if (stylesheet.rules.length) {
      stylesheet.deleteRule(0);
    }

    stylesheet.insertRule(
      ".device_scale {-webkit-transform:scale(" + getDeviceScale() + ")}", 0
    );
  }

  // Adapted from code by Mislav Marohnić: http://gist.github.com/355625
  function getDeviceScale() {
    var deviceWidth, landscape = Math.abs(window.orientation) == 90;

    if (landscape) {
      // iPhone OS < 3.2 reports a screen height of 396px
      deviceWidth = Math.max(480, screen.height);
    } else {
      deviceWidth = screen.width;
    }

    return window.innerWidth / deviceWidth;
  }
})();

View/Share Gist