ZUI Site Riot

by David DeSandro

Written for .net magazine

Project source on GitHub: github.com/desandro/zui-site-riot

Demos


ZUI Site Riot

Tutorial info

  • Knowledge needed: CSS, Intermediate JavaScript
  • Requires: text editor, browser that supports CSS3 transforms
  • Project time: 4 hours

With CSS3 transforms now supported in most major browsers, we have the delightful opportunity to experiment creating innovative layouts and interfaces. While the paradigm of the vertical website will continue to prosper, there’s a world of possibilities out there to explore. No longer are we shackled in our one-dimensional prisons, bound to the tyranny of vertically-scrolling sites.

With the site for BeerCamp at SXSW 2011 (2011.beercamp.com), we at nclud recognized an ideal opportunity to bend some rules and try something new. I got the idea to leverage CSS transforms for the layout. Instead of the typical vertical scrolling site, where you traversed it downwards, this would could be traversed inwards. This is sort of design pattern has been categorized as a Zoomable User Interface or ZUI.

The BeerCamp at SXSW 2011 was an experiment in using CSS transforms to create a new interface design pattern.

View hi-res

BeerCamp at SXSW 2011

In this tutorial, let’s build a Zoomable User Interface into a simple page. We will learn:

  • How to use CSS transforms to layout a Zoomable User Interface
  • How to hi-jack scrolling to manipulate the zoom
  • How to add CSS transitions to make an impressive interaction

Our example page lists the services of a web development shop. The services listed range from the broad to the specific. This content is well suited for a zoomable layout, as the subsequent sections are smaller segments of the previous sections. By placing one visual element inside another, you are visually communicating the relationship between pieces of content. The sections for our page are:

  1. Web development
  2. Front-end development
  3. CSS
  4. CSS3
  5. Transforms

Basic layout

Before we create a zoomable layout, we need to set up a basic layout that is designed for browsers without support CSS transforms or browsers with JavaScript disabled.

ZUI Site Riot - Demo 1 - Basic Layout screenshot

View hi-res

When laying out the sections of this page, we need to consider how each section will fit inside one another. I’ve chosen to use a ratio of 3:1 for the proportion between the current section and its subsequent section. In addition to adhering to the ideal compositional Rule of Thirds, this is a reasonable ratio for parent/child sizes and the parent will have enough room for content, and the child container will still be visible. The area of each section will be 900px x 540px so it fits within most browser windows. Subsequent sections will have dimensions one-third the full size, 300px x 180px.

The basic markup of the site will look like the following:

<div id="wrap">
  <div id="container">
    <ul id="nav">
      <li><a href="#web-dev">Web development</a></li>
      <li><a href="#front-end">Front-end development</a></li>
      <li><a href="#css">CSS</a></li>
      <li><a href="#css3">CSS3</a></li>
      <li><a href="#transforms">Transforms</a></li>
    </ul>
    <div id="content">
      <section id="web-dev">...</section>
      <section id="front-end">...</section>
      <section id="css">...</section>
      <section id="css3">...</section>
      <section id="transforms">...</section>
    </div> <!-- #content -->
  </div> <!-- #container -->
</div> <!-- #wrap -->

The first draft of the site has the content laid out in the typical vertical pattern. This version is important as this is what we can expect to provide to browsers that do not support CSS transforms or have JavaScript disabled. The rest of the effects will be built with progressive enhancement. Users with less-capable browsers will still be able to consume the content.

You’ll notice that each section has an empty space in the center of it. This space has been reserved for the subsequent sections to fit inside it once CSS transforms are put in place.

See Demo 1 - Basic layout (desandro.github.com/zui-site-riot/demo1.html)

Adding transforms

With the basic layout established, we can now start adding CSS transforms. Before we jump in with the CSS, let’s add Modernizr so we have more control over how browsers will inherit their styles. I opted to use a custom build from the Modernizr 2.0 beta preview, that only tests for CSS 2D transforms and CSS transitions (modernizr.github.com/Modernizr/2.0-beta/#csstransforms-csstransitions-iepp). After adding the Modernizr code to our scripts, we can target browsers that support transforms with .csstransforms in our CSS.

To scale each section inside one another, they first need to occupy the same space. This can be done with absolute positioning.

/* absolute positioning */
.csstransforms #container { position: relative; }
.csstransforms #content { position: absolute; }
.csstransforms section { position: absolute; }

Each section needs to have its own scale set. As the proportion we are using is 3:1, essentially each section will be one third the size of the previous. This can be calculated as the inverse ration to the exponent of the level’s zero-based index:

zoomScale = inverse ratio ^ zero-based-level

The scale of first level, #web-dev is (1/3) ^ 0 or just 1, so we don’t need to set that superfluous style. The scale of the second level, #front-end is (1/3) ^ 1 or 1/3 or in decimal 0.3333. The scale of the third level #css is (1/3) ^ 2 or 1/9 or in decimal 0.1111. We’ll apply this value to the various vendor-prefix transform CSS properties for all four of our subsequent sections.

/* level index 1: (1/3) ^ 1 = 1/3 = 0.3333 */
.csstransforms #front-end {
  -webkit-transform: scale(0.3333);
     -moz-transform: scale(0.3333);
       -o-transform: scale(0.3333);
          transform: scale(0.3333);
}

 /* level index 2: (1/3) ^ 2 ) = 1/9 = 0.1111 */
.csstransforms #css {
  -webkit-transform: scale(0.1111);
     -moz-transform: scale(0.1111);
       -o-transform: scale(0.1111);
          transform: scale(0.1111);
}

/* level index 3: (1/3) ^ 3 = 1/27 = 0.0370 */
.csstransforms #css3 {
  -webkit-transform: scale(0.037);
     -moz-transform: scale(0.037);
       -o-transform: scale(0.037);
          transform: scale(0.037);
}

/* level index 4: (1/3) ^ 4 = 1/81 = 0.0123456 */
.csstransforms #transforms {
  -webkit-transform: scale(0.0123456);
     -moz-transform: scale(0.0123456);
       -o-transform: scale(0.0123456);
          transform: scale(0.0123456);
}

Awesome! The sections have been transformed to fit inside one another, like Russian nesting dolls.

See Demo 2 - Scaled sections (desandro.github.com/zui-site-riot/demo2.html)

Each section is positioned inside one another using CSS scale transforms. You can see the second section within the first, and if you look closely, you'll find the others deeper within.

View hi-res

Demo 2 - Scaled sections screenshot

Now we need to build a mechanism to enable the user the zoom in. To zoom in to a section, we only need to apply its reciprocal scale to the sections’ parent #content. All child sections will be scaled up accordingly. In mathematical terms, the scale is equal to the ratio to the exponent of level’s zero-base index. The second section #front-end has a scale of 1/3, so it needs to be scaled 3x to bring it to 100% size.

zoomScale = ratio ^ zero-based-level

The scale to view the third level would be 3 ^ 2 = 9. For the fourth level, the scale would be 3 ^ 3 = 27.

/* view #css3, level index 3 = 3 ^ 3 = 27 */
.csstransforms #content {
  -webkit-transform: scale(27);
     -moz-transform: scale(27);
       -o-transform: scale(27);
          transform: scale(27);
}

See Demo 3 - Fixed zoom (desandro.github.com/zui-site-riot/demo3.html)

Applying a scale that increases the size of container will zoom in on its content.

View hi-res

Demo 3 - Fixed zoom

Scroll

Leveraging window scrolling is a natural convenient interaction to hook zooming into. Along side clicking and pointing, scrolling is a natural interaction that anyone with a mouse or keyboard uses.

Currently there is not anything to scroll, since the entire page is self contained in that 900 x 540 area. But we can fake it by adding an empty element that has height, which will serve as our proxy. The mark up will be added after #wrap.

</div> <!-- #wrap -->
<div id="scroller"></div>

In the CSS, set an arbitrary height on #scroller. 4000px works, as an approximate height of the page before we added the scale transforms.

.csstransforms #scroller { height: 4000px; }

But we don’t want the content to scroll with the rest of the page, so we can use fixed positioning to fix the actual content in its same place.

/* prevent content from scrolling */
#wrap { 
  position: fixed; 
  width: 100%;
}

The page scrolls, but the content remains static. Now the fun begins as we jump into scriptin’.

JavaScript

The basic idea is that we are going to hijack the scroll event and do something with it.

Hijacking the scroll behavior enables users to zoom in to inner content.

Get hi-res 'scroll' screenshots

Demo 4 - Scroll to zoom

As all this script will only need to run if the browser supports CSS transforms, we can encapsulate our entire script in a self-executing function, which will only proceed if CSS transforms are supported.

(function(){
  // only proceed if CSS transforms are supported
  if ( !Modernizr.csstransforms ) {
    return;
  }
  // CSS transforms supported, continue...
})();

I’m using a constructor design pattern Zoomer which will do all the work. The Zoomer constructor requires a DOM node, specifically the content container, which will be passed in later. It holds properties like scrolled which will be the vertical scroll position, levels which is the zero-based number of sections, and docHeight. Most importantly, we pass it in as an event listener to the window’s scroll event.

// the constructor that will do all the work
function Zoomer( content ) {
  // keep track of DOM
  this.content = content;
  
  // position of vertical scroll
  this.scrolled = 0;
  // zero-based number of sections
  this.levels = 4;
  // height of document
  this.docHeight = document.documentElement.offsetHeight;
  
  // bind Zoomer to scroll event
  window.addEventListener( 'scroll', this, false);
}

The handleEvent method allows the constructor to be used within an event listener. If a method matches the event’s type, that method will be called. So we can bind Zoomer.prototype.scroll to the window’s scroll event.

// enables constructor to be used within event listener
// like obj.addEventListener( eventName, this, false )
Zoomer.prototype.handleEvent = function( event ) {
  if ( this[event.type] ) {
    this[event.type](event);
  }
};

Zoomer.prototype.scroll is where the magic will be happening. We first need to calculate the current position of the scroll relative the height of the page.

// triggered every time window scrolls
Zoomer.prototype.scroll = function( event ) {
  // normalize scroll value from 0 to 1
  this.scrolled = window.scrollY / ( this.docHeight - window.innerHeight );
};

We can take that scrolled value and use it for our scale value to zoom into the content. We can use the same math we applied with the CSS above, except we are now using a percentage. The percentage goes from 0 to 1, so we need to multiply it by the zero-based number of sections.

zoomScale = ratio ^ ( percentage * levels )

This value can be applied as a CSS transform. We need to set all the vendor specific CSS properties for transform with the transformValue.

// triggered every time window scrolls
Zoomer.prototype.scroll = function( event ) {
  // normalize scroll value from 0 to 1
  this.scrolled = window.scrollY / ( this.docHeight - window.innerHeight );

  var scale = Math.pow( 3, this.scrolled * this.levels ),
      transformValue = 'scale('+scale+')';

  this.content.style.WebkitTransform = transformValue;
  this.content.style.MozTransform = transformValue;
  this.content.style.OTransform = transformValue;
  this.content.style.transform = transformValue;
};

All that’s left is to initialize a new Zoomer instance, pass in the #content DOM node, and start it up on

function init() {
  var content = document.getElementById('content'),
      // init Zoomer constructor
      ZUI = new Zoomer( content );
}

window.addEventListener( 'DOMContentLoaded', init, false );

Now the content zooms when you scroll. Whooaaaaaaaaa.

See Demo 4 - Scroll to zoom (desandro.github.com/zui-site-riot/demo4.html)

If done correctly, when you scroll to the bottom of the page, you'll zoom in perfectly to the last section.

View hi-res

Transform end

navigation

You can't make any omelets without breaking a couple eggs. Enabling a ZUI, means disabling anchor links. We'll need to develop a solution.

View hi-res

Try clicking the page navigation. Since implementing the transforms, we’ve broken it because there’s nothing to scroll to – all the content is held within itself. To resolve this, we’ll need to bind an event to the navigation, just like we did with the window scrolling.

As we did with #content, we’ll need to pass the DOM nodes of the navigation into the Zoomer instance. We’ll need another property that will return a zero-based index if we provide a hash, seen here as levelGuide.

function Zoomer( content, navLinks ) {
  // keep track of DOM
  this.content = content;
  this.navLinks = navLinks;
  // ...
  this.levelGuide = {
    '#web-dev' : 0,
    '#front-end' : 1,
    '#css' : 2,
    '#css3' : 3,
    '#transforms' : 4
  };
}
// ...
function init() {
  var content = document.getElementById('content'),
      navLinks = document.querySelectorAll('#nav a'),
      // init Zoomer constructor
      ZUI = new Zoomer( content, navLinks );
}

The Zoomer instance ZUI can then be used as an event listener for when any of the navigation <a> are clicked.

function Zoomer( content, navLinks ) {
  // ...
  // bind Zoomer.click to nav item clicks
  for ( var i=0, len = this.navLinks.length; i < len; i++ ) {
    this.navLinks[i].addEventListener( 'click', this, false );
  }
} 

Zoomer.prototype.click will control what happens when a navigation link is clicked. We can take the hash of the clicked element and pass it to another method scrollFromHash.

// triggered on nav click
Zoomer.prototype.click = function( event ) {
  //  get scroll based on href of clicked nav item
  var hash = event.target.hash || event.target.parentNode.hash;
  this.scrollFromHash( hash );
  event.preventDefault();
};

With the hash, we can determine the level of its destination from the levelGuide. This value then need to be interpolated as a distance in pixels from 0 to the scrollable height of the page. Set here as scrollY. We use this value to set the scroll position of the page. Once we apply scrollY to window.scrollTo, the window scroll event is triggered, which in turn triggers Zoomer.scroll. The consequential transform is already take care for us.

// reverse engineer scroll position from hash
Zoomer.prototype.scrollFromHash = function( hash ) {
  var targetLevel = this.levelGuide[ hash ];
  // proceed only if hash matches a level
  if ( targetLevel === undefined ) {
    return;
  }
  var scrollY = targetLevel / this.levels;
  // adjust for scrollable height
  scrollY = scrollY * ( this.docHeight - window.innerHeight );
  // set hash in location URL
  window.location.hash = hash;
  // set scroll position, Zoomer.scroll will take care of the rest
  window.scrollTo( 0, scrollY );
};

Now when we click the navigation, the zoom is updated accordingly. We can also use the scrollFromHash method to zoom in if the page is loaded with a hash. Within init():

// scroll/zoom to hash if available 
function scrollToHash() {
  if ( window.location.hash ) {
    ZUI.scrollFromHash( window.location.hash );
  }
}
window.addEventListener( 'load', scrollToHash, false );

Active navigation

Zooming from section to section is such a new interaction pattern, that we want to help our users out as much as possible. We can guide them by actively highlighting the current section they are viewing. Within Zoomer.prototype.scroll we need only to determine the current level the user is viewing:

// change current selection on nav
this.currentLevel = Math.round( this.scrolled * this.levels );

If the level is different than the previous one, highlight its corresponding navigation item by toggling the current class.

if ( this.currentLevel !== this.previousLevel ) {
  // remove previous currentNavItem
  if ( this.currentNavLink ) {
    this.currentNavLink.className = '';
  }
  // select new currentNavItem
  this.currentNavLink = this.navLinks[ this.currentLevel ];
  this.currentNavLink.className = 'current';
  this.previousLevel = this.currentLevel;
}

You can style the .current class however you like in your CSS.

See Demo 5 - Navigation (desandro.github.com/zui-site-riot/demo5.html)

Transitions

CSS3 logo

CSS3 is the principal technology that enables the ZUI for this page. We are leveraging transforms and transitions.

View hi-res

The navigation works as it should, but jumping from zoom to zoom is a bit disorienting. We can help the user understand what changed by add CSS transitions. In the CSS, add the styles to enable CSS transitions.

/* enables transitions */
.csstransitions #content.transitions-enabled {
  -webkit-transition: -webkit-transform 1s;
     -moz-transition:    -moz-transform 1s;
       -o-transition:      -o-transform 1s;
          transition:         transform 1s;
}

We cannot just add transitions, as they will interfere with the numerous transforms that get applied each time the window scrolls. Instead, we’ll want to enable transitions only when the navigation is clicked. Within Zoomer.prototype.click transitions can be enabled by adding the transitions-enabled class to #content. As we need to disable the transition after the it has completed, we can add event listeners for the transition end event. The multiple event listeners are for different browsers. 'webkitTransitionEnd' for WebKit, 'oTransitionEnd' for Opera, and 'transitionend' for Firefox 4.

if ( Modernizr.csstransitions ) {
  this.content.className = 'transitions-enabled';
  this.content.addEventListener( 'webkitTransitionEnd', this, false );
  this.content.addEventListener( 'oTransitionEnd', this, false );
  this.content.addEventListener( 'transitionend', this, false );
}

Again, we’re leveraging the handleEvent method to be able to pass in the constructor. The respective methods only need to remove the transitions-enabled class and the event listeners.

Zoomer.prototype.webkitTransitionEnd = function( event ) {
  this.transitionEnded( event );
};
Zoomer.prototype.transitionend = function( event ) {
  this.transitionEnded( event );
};
Zoomer.prototype.oTransitionEnd = function( event ) {
  this.transitionEnded( event );
};
// disables transition after nav click
Zoomer.prototype.transitionEnded = function( event ) {
  this.content.className = '';
  this.content.removeEventListener( 'webkitTransitionEnd', this, false );
  this.content.removeEventListener( 'transitionend', this, false );
  this.content.removeEventListener( 'oTransitionEnd', this, false );
};

Try clicking on the navigation. If your browser supports CSS transitions, you’ll be pleasantly whisked away zooming towards the appropriate section.

See Demo 6 - Transitions (desandro.github.com/zui-site-riot/demo6.html)

Completion

Behold what you have accomplished! Not only have you produced a pioneer in the realm of web interface development, but you have built it in such a way that it will be fun to use and engaging to interact with. Well done!

About the author

  • Name: David DeSandro
  • Site: desandro.com
  • Expertise: Creative programming with CSS and JavaScript
  • Clients: Apple, Oracle, Mashable
  • Photo Headshot by Dave DeSandro, on Flickr Hi-res available

Zooming overview

As a web designer, you are already well familiar with the concept of a GUI. But have you ever heard of a ZUI - zoomable user interface? The term might be new, but the zooming design pattern is a popular concept. Zooming is directly related to manipulating scale - how the size of the interface elements compare with one another. ZUIs can be found all across the digital landscape.

  • Its most literal form can be found in vector applications like Illustrator or 3D applications like Google Sketch-up, where zooming translates into getting closer in on smaller objects.

  • On the web, the most commonplace example is on map sites like Google Maps or Mapquest. Google Maps was clever enough to integrate the scroll wheel with zoom.

  • Plenty of desktop application have zooming capabilities. Microsoft Word and Powerpoint both are able to increase the visible portion of interface. Zooming has been built into all modern web browser, enabling users to adjust websites to a comfortable size that they can read and interact with.

  • Mac OS X has zooming built into its interface. Holding Control and scrolling will zoom the entire screen towards the cursor.

  • Several flash sites have tried to trailblaze the way for ZUIs: zoomquilt.nikkki.net and zoomism.com

Now in the age of mobile with iOS and Android browsers, zooming has been leveraged to allow devices with small screens display content designed for desktops. Zooming enables users to navigate within the page. The iPhone introduced several key behaviors related to zoom. Instead of relying on a UI element, it relied on the pinch/spread gesture to control zoom. This behavior is consistent through Maps and Mobile Safari.

Given all these examples, you can rest assure that building zooming into your web app will not be that revolutionary. Users might not expect it at first, but clearly, they have used zooming in some capacity in the past.

Vanilla, my favorite flavor of JavaScript

If you have been glancing over the code snippets for this tutorial, you may have noticed that jQuery old familiar’s $ is nowhere to be found. Like most front-end developers, I came to learn JavaScript from first being introduced to jQuery. The jQuery framework was especially welcoming with its CSS familiarity and approachable syntax that enabled novices like myself to start playing around with the DOM.

As I have come to learn more about jQuery, and therein JavaScript, I have tried to ween myself off of using JavaScript frameworks. There’s nothing inherently wrong with them. But leaving them for vanilla JavaScript has its advantages.

  • Size: jQuery minified comes in at 30kb. Compare that to the 8kb of the tutorial script, uncompressed. That’s dead weight that can be easily lost.

  • Clarity: When you cut out using a framework, you can rely on other developers being better versed in reading your code. Frameworks, no matter how efficient their API, will always obfuscate the original functionality of JavaScript.

  • Progressive enhancement: The scripts employed in this tutorial are designed only for modern browsers that support CSS transforms. So we don’t have to worry about building in support for older browsers.

  • Modern browser capabilities: Prior to the rise of frameworks like jQuery, trying to hook in to any element on the page was tedious, messy, and especially frustrating. jQuery made a huge leap forward with its selector engine. Since then, browser vendors have clued and built this functionality straight into their own browsers. querySelectorAll gives you the easy selection without having to go through a browser.

If you are targeting modern browsers and your project is a smaller one, consider using plain ol’ vanilla JavaScript. You’ll learn more about the language and also come to appreciate what the frameworks have to offer.