Build a lightbox for a responsive HTML5 touch interface
Stephen Woods, frontend engineer at Flickr, explains how to create a simple lightbox with gesture support and provides tips for improving the perceived as well as the actual performance of touch interfaces.
- Knowledge needed: Intermediate CSS, intermediate to advanced JavaScript
- Requires: Android or iOS touch device
- Project Time: 2-3 hours
- Support file
Lightbox widgets have been standard around the web since the original lightbox.js was released in 2005. A lightbox creates a modal dialog box for viewing large images, typically with 'next' and 'previous' buttons to navigate between slides.
Since the explosion in usage of touch devices websites have updated their lightboxes to support gesture interaction with varying degrees of success. In this tutorial I am going to show you how to create a simple lightbox with gesture support. In the process you will learn a little about improving the perceived performance of touch interfaces, as well as some simple tricks to improve the actual performance.
Writing code for touch devices is fundamentally different than writing code for a desktop. You can (and should) share as much code as you can with desktop code, but there are always going to be large differences in what you do.
Benchmarks show that the most common touch devices are comparable in performance to desktop computers circa 1998. They usually have about 256MB of RAM and CPU performance on par with the original iMac.Techniques we are used to 'just working' on the desktop won't work well at all on mobile phones and tablets. Luckily for us these devices generally are well optimised for graphics, particularly moving elements around on the screen. iOS devices and Android 3.0 and above have hardware graphics acceleration available.
Effectively, you can imagine these devices as crappy computers with decent video cards.
We have been interacting with our desktops in more or less the same way for the last 20 years. We move a mouse pointer and click on controls. Buttons, close boxes, links, and scroll bars are second nature to both users and developers. Touch interfaces introduce a completely different set of conventions. One of the most common of the new conventions is the swipe. With a 'swipe' multiple items are presented as if they were in a row, and the user can use a 'swiping' gesture to navigate between them.
The swipe is such a common pattern you do not even need to tell users about it – when presented with what looks like a list, users will instinctively try swiping.
Frequently we can't actually make our code go any faster, especially when we are dealing with slow connections and slow devices. But we can make the interface seem faster by focusing on the right optimisations.
My favorite example of optimising for perceived performance is the TiVo. Thirteen years ago, when the first TiVo boxes appeared, they were incredibly slow. (16MB of RAM and a 54mhz CPU!) It could take a painfully long time for something to happen after you clicked something with the remote, particularly if you were starting to play or record. Nevertheless, nobody ever complained about the TiVo being slow. In my opinion that is because of the sound. The most familiar part of the TiVo interface was the 'beep boop' sound that played after you click a button. That sound plays instantly. The engineers at TiVo ensured that that sound loaded as fast as possible, so that no matter what happened next the user knew that the interface hadn't died. That little sound tells the user that their request had been heard.
On the web we've developed a convention that does the same thing: the spinner. After a click we throw up a spinner graphic immediately, so the user gets the message that they have been heard. On mobile we have to do things differenly
Gestures are not discrete actions like clicks. Nevertheless, in order to make an interface seem fast we must give users some feedback. As they gesture we move the interface in some way so that they know we are 'hearing' them.
01. The tools
Interfaces that feel responsive require that elements move as quickly as possible – the movement is how we show the user that the interface is responding to their request. Using JavaScript animations for this is too slow. Instead we use CSS transforms and transitions: transforms for performance, and transitions so that animations can run without blocking JavaScript execution.
Throughout this tutorial I am going to use transforms and transitions for all movements and animations.
Another optimisation I like to use as much as possible is something I call the 'write-only DOM'. Reading properties and values out of the DOM is expensive and usually unnecessary. For the lightbox I try and combine all the reads into the initialisation phase. After that I maintain state in JavaScript and do simple arithmetic when necessary.
02. Building the lightbox
For this tutorial we are going to build a page with a few thumbnails. Clicking (or tapping) on the thumbnails will launch a lightbox. Once inside the lightbox, the user will be able to swipe between images, and then tap a 'close' button to leave the lightbox.
When building the gesture interface, keep in mind the importance of perceived performance. In the lightbox that means making sure that the slides move as the user swipes. When the user stops gesturing the slides should animate into the next position, or 'snap back' if the slide does not advance.
The 'snap back' animation is critical. That is how to make sure the user never feels like the interface died.
03. Getting started
Creating the following files:
lightbox/
reset.css
slides.css
slides.html
slides.js
04. Boilerplate
The HTML is going to be simple. This is not just for the sake of the demo. A complex DOM tree by definition is slower. Styles, dom element retrieval, and repaint are all more expensive with a more complex DOM tree. Because we are targeting crappy computers, every bit counts so keep it simple from the beginning.
I'm using reset.css by Eric Meyer to start with a clean CSS slate. I am also setting up the viewport so that the page is not zoomable.
I have disabled native pinch to zoom because it will interfere with the gestures. (The correct way to support the pinch gesture is re-implementing it in JavaScript. Pinch to zoom deserves its own tutorial, so we'll disregard that for now.)
On the JS side I'm using zepto.js, a very lightweight framework with jQuery syntax. No framework is really necessary, but it speeds up work a little on some mundane tasks. For the actual gesture interactions, we'll use the native APIs.
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0, maximum-scale=1.0,user-scaleable=no">
<title>Touch Interface Demo</title>
<link rel="stylesheet" href="reset.css" type="text/css" media="screen" charset="utf-8">
<link rel="stylesheet" href="slides.css" type="text/css" media="screen" harset="utf-8">
</head>
<body>
</body>
<script src="zepto.min.js" type="text/javascript" charset="utf-8"></script>
</html>
Get the Creative Bloq Newsletter
Daily design news, reviews, how-tos and more, as picked by the editors.
05. The HTML
I created a div that contains an unordered list of thumbnails. The only piece that is special is the data attributes. For each image I included data-full-width and data-full-height with the height and width of the full size image the thumbnail represents. I could get this number from the image once it is fetched, but having it up front makes it possible to load preview images and build the nodes without making the user wait for server responses.
<a href="http://www.flickr.com/photos/protohiro/6664939239/in/photostream/">
<img data-full-height="427" data-full-width="640" src="http://farm8.staticflickr.com/7142/6664939239_7a6c846ec9_s.jpg">
</a>
</li>
<li>
<a href="http://www.flickr.com/photos/protohiro/6664957519/in/photostream">
<img data-full-height="424" data-full-width="640" src="http://farm8.staticflickr.com/7001/6664957519_582f716e38_s.jpg">
</a>
</li>
<li>
<a href="http://www.flickr.com/photos/protohiro/6664955215/in/photostream">
<img data-full-height="640" data-full-width="427" src="http://farm8.staticflickr.com/7019/6664955215_d49f2a0b18_s.jpg">
</a>
</li>
<li>
<a href="http://www.flickr.com/photos/protohiro/6664952047/in/photostream">
<img data-full-height="426" data-full-width="640" src="http://farm8.staticflickr.com/7017/6664952047_6955870ecb_s.jpg">
</a>
</li>
<li>
<a href="http://www.flickr.com/photos/protohiro/6664948305/in/photostream">
<img data-full-height="428" data-full-width="640" src="http://farm8.staticflickr.com/7149/6664948305_fb5a6276e5_s.jpg">
</a>
</li>
</ul>
</div>
</div>
</body>
<script src="zepto.min.js" type="text/javascript" charset="utf-8"></script>
<script src="slides.js" type="text/javascript" charset="utf-8"></script>
<script type="text/javascript" charset="utf-8">
//this code initializes the lightbox and shows it when the user
//clicks on a slide
$(document).ready(function(){
var lightbox = new saw.Lightbox('.carousel');
$(document).on('click', 'a', function(e){
e.preventDefault();
lightbox.show(this.href);
});
});
</script>
</html>
06. Styling the thumbnails
Now add little pretty thumbnails and some other visual flourishes:
html {
background: #f1eee4;
font-family: georgia;
color: #7d7f94;
}
h1 {
color: #ba4a00;
}
.welcome {
text-align: center;
text-shadow: 1px 1px 1px #fff;
}
.welcome h1 {
font-size: 20px;
font-weight: bold;
}
.welcome {
-webkit-box-sizing: border-box; /* Safari/Chrome, other WebKit */
-moz-box-sizing: border-box; /* Firefox, other Gecko */
box-sizing: border-box; /* Opera/IE 8+ */
margin:5px;
padding:10px;
box-shadow: 2px 2px 5px rgba(0,0,0,0.5);
border-radius: 5px;
}
.carousel {
margin:5px;
}
.carousel ul li {
height: 70px;
width: 70px;
margin: 5px;
overflow: hidden;
display: block;
float: left;
border-radius: 5px;
box-shadow: 1px 1px 2px rgba(0,0,0,0.5), -1px -1px 2px rgba(255,255,255,1);
}
The basic lightbox
The JavaScript for the lightbox needs to do a few different things:
- Gather the data about the lightbox and initalise
- Hide and show the lightbox
- Create the HTML for the lightbox chrome
- Build the slides
- Handle touch events
07. Utility functions
Instead of typing -webkit-transform and translate3d over and over again, I create a few utility functions to do the work for me.
function prefixify(str) {
var ua = window.navigator.userAgent;
if(ua.indexOf('WebKit') !== -1) {
return '-webkit-' + str;
}
if(ua.indexOf('Opera') !== -1) {
return '-o-' + str;
}
if(ua.indexOf('Gecko') !== -1) {
return '-moz-' + str;
}
return str;
}
function setPosition(node, left) {
// node.css('left', left +'px');
node.css(prefixify('transform'), "translate3d("+left+"px, 0, 0)");
}
function addTransitions(node){
node.css(prefixify('transition'), prefixify('transform') + ' .25s ease-in-out');
node[0].addEventListener('webkitTransitionEnd', function(e){
window.setTimeout(function(){
$(e.target).css('-webkit-transition', 'none');
}, 0)
})
}
function cleanTransitions(node){
node.css(prefixify('transition'), 'none');
}
Our lightbox widget is going to be initialised at page load to speed things up. Initialisation is a matter of finding all the thumbnails on the page to build a data model. We will wait for when the lightbox is shown to build the HTML for the lightbox and attach event handlers.
08. Initialisation
For the lightbox object I use a constructor that takes a container node selector as its only parameter.
//clean namespacing
window.saw = (function($){
//the lightbox constructor
function Lightbox (selector) {
var container_node = $(selector),
wrapper,
chromeBuilt,
currentSlide = 0,
slideData =[],
boundingBox = [0,0],
slideMap = {};
function init(){
//init function
}
return {
show: show,
hide: hide
};
}
return {
Lightbox:Lightbox
};
}($));
The init function grabs all the li nodes, finds the thumbnails, and records the information in the slideData array. At the same time I keep an object called slideMap that maps the href of the thumbnail link to the slideData element in the array. This lets me quickly look up the data from the click information without having to loop through all the data in the array or decorate the DOM with additional information.
function init(){
var slides = container_node.find('li');
slides.each(function(i, el){
var thisSlide = {}, thisImg = $(el).find('img');
thisSlide.url = thisImg.attr('src');
thisSlide.height = thisImg.attr('data-full-height');
thisSlide.width = thisImg.attr('data-full-width');
thisSlide.link = $(el).find('a').attr('href');
//push the slide info into the slideData array while recording the array index in the slideMap object.
slideMap[thisSlide.link] = slideData.push(thisSlide) - 1;
});
}
The rest of the initialisation happens in the show method.
//this is the function called from the inline script
function show(startSlide){
if(!chromeBuilt){
buildChrome();
attachEvents();
}
wrapper.show();
//keep track of the viewport size
boundingBox = [ window.innerWidth, window.innerHeight ];
goTo(slideMap[startSlide]);
}
09. Building the chrome
The buildChrome function creates the HTML wrapper for the lightbox and then sets a semaphore so that the chrome doesn't get re-built each time the user hides or shows the lightbox. For simplicity's sake I've created a separate template function for the HTML itself:
var wrapperTemplate = function(){
return '<div class="slidewrap">'+
'<div class="controls"><a class="prev" href="#">prev</a> | <a class="next" href="#">next</a></div>'+
'</div>';
}
function buildChrome(){
wrapper = $(wrapperTemplate()).addClass('slidewrap');
$('body').append(wrapper);
chromeBuilt = true;
}
The last step in building the chrome is attaching an event handler for the 'next' and 'previous' links:
function handleClicks(e){
e.preventDefault();
var targ = $(e.target);
if(targ.hasClass('next')) {
goTo(currentSlide + 1);
} else if(targ.hasClass('prev')){
goTo(currentSlide - 1);
} else {
hide();
}
}
function attachEvents(){
wrapper.on('click', handleClicks, false);
}
Now the lightbox chrome is ready for some actual slides. In my show function I call goTo() to load the starting slide. This function shows the slide identified by the parameter, but it will also lazily build the slides as I need them. (Aside: do not call a function goto with no camelcase, goto is a reserved word in JavaScript).
10. Building the slides
Now the slide I am looking at is in the viewport, and the previous and next slides are to the left and right, just off screen. When the user clicks (or swipes) 'next', the current slide is moved off to the left and the next slide is moved into position.
//for the slides, takes a "slide" object
function slideTemplate(slide){
return '<div class="slide"><span>'+slide.id+'</span><div style="background-image:url('+slide.url.replace(/_s|_q/, '_z')+')"></div></div>';
}
I am using a <div> rather thatn an <img> because (at least for now) mobile browsers are much slower at drawing an <img> than a <div> with a background image. When dealing with mobile devices, fast is generally preferable to correct. Accessibility issues can easily be addressed with ARIA roles.
The buildSlide function itself is more complex. In addition to pushing the slide data through the slide template, the code also has to make sure that the slide fits in the viewport. This is a simple matter of figuring out how much to scale the image if it doesn't fit. We can let the browser handle resizing.
function buildSlide (slideNum) {
var thisSlide, s, img, scaleFactor = 1, w, h;
if(!slideData[slideNum] || slideData[slideNum].node){
return false;
}
var thisSlide = slideData[slideNum];
var s = $(slideTemplate(thisSlide));
var img = s.children('div');
//image is too big! scale it!
if(thisSlide.width > boundingBox[0] || thisSlide.height > boundingBox[1]){
if(thisSlide.width > thisSlide.height) {
scaleFactor = boundingBox[0]/thisSlide.width;
} else {
scaleFactor = boundingBox[1]/thisSlide.height;
}
w = Math.round(thisSlide.width * scaleFactor);
h = Math.round(thisSlide.height * scaleFactor);
img.css('height', h + 'px');
img.css('width', w + 'px');
}else{
img.css('height', thisSlide.height + 'px');
img.css('width', thisSlide.width + 'px');
}
thisSlide.node = s;
wrapper.append(s);
//put the new slide into the start poisition
setPosition(s, boundingBox[0]);
return s;
}
11. goTo
The goTo moves the requested slide and adjacent slides into position.
function goTo(slideNum){
var thisSlide;
//if the slide we are looking for doesn't exist, lets just go
//back to the current slide. This has the handy effect of providing
//"snap back" feedback when gesturing, the slide will just animate
//back into position
if(!slideData[slideNum]){
return;
}
thisSlide = slideData[slideNum];
//build adjacent slides
buildSlide(slideNum);
buildSlide(slideNum + 1);
buildSlide(slideNum - 1);
//make it fancy
addTransitions(thisSlide.node);
//put the current slide into position
setPosition(thisSlide.node, 0);
//slide the adjacent slides away
if(slideData[slideNum - 1] && slideData[slideNum-1].node){
addTransitions(slideData[slideNum - 1 ].node);
setPosition( slideData[slideNum - 1 ].node , (0 - boundingBox[0]) );
}
if(slideData[slideNum + 1] && slideData[slideNum + 1].node){
addTransitions(slideData[slideNum + 1 ].node);
setPosition(slideData[slideNum + 1 ].node, boundingBox[0] );
}
//update the state
currentSlide = slideNum;
}
At this point the lightbox is more or less functional. We can go to the next and previous slide, we can hide and show. It would be ideal know when they reach the first and last slide, maybe by graying out the controls. This is a useable lightbox, on a desktop or a touch device.
12. Adding gesture support
Most touch devices include a native photo viewer. These various apps, following the original iPhone photos app, have created a UI convention: a swipe to the left advances the slide. I've seen several implementations of this interaction that give no feedback at all; the slides simply advance when the gesture is completed. The best approach is to give live feedback. As the user swipes, the slide moves under the users finger and the next (or previous) slide appears to the left or right. This give the illusion that the user is pulling along a strip of photos.
13. Listening for touch events
Many libraries, including Zepto, include support for touch events. In general I do not recommend using them. When handling touch events you are updating an element live as the user gestures. This spot is where delay at all is going to be immediately obvious to a user: it will feel slow. Adding a layer of indirection will necessarily affect performance. Indirection is never free. One of the main reasons we have used libraries for events is to provide a browser normalisation layer. All the mobile browsers that support touch events have the same API.
There are three touch events to consider for this example: touchstart, touchmove and touchend. There is also a touchcancel event, when the gesture is interrupted for some reason (such as a push notification). In production you should handle this gracefully.
function attachTouchEvents() {
var bd = document.querySelector('html');
bd.addEventListener('touchmove', handleTouchEvents);
bd.addEventListener('touchstart', handleTouchEvents);
bd.addEventListener('touchend', handleTouchEvents);
}
The event handler receives a TouchEvent object. The touchstart and touchmove events contain a touches property which is an array of Touch objects. Only one property is necessary for swipes: clientX. This value is the position of the touch relative to the top left of the page.
iOS devices support up to eleven touches. Android (before Ice Cream Sandwich) only contains one. Most interactions require only one touch. More complex gestures mean worrying about multiple touches.
14. The handleTouchEvents function
First define a few variables outside this function to maintain state:
var startPos, endPos, lastPos;
Next branch based on the type property of the event object:
function handleTouchEvents(e){
var direction = 0;
//you could also use a switch statement
if(e.type == 'touchstart') {
} else if(e.type == 'touchmove' ) {
} else if(e.type == 'touchend) {
}
The touchstart event fires at the beginning of any touch event, so use it to record where the gesture started, which will be important later. Clean off any transitions that might still be on the nodes.
if(e.type == 'touchstart') {
//record the start clientX
startPos = e.touches[0].clientX;
//lastPos is startPos at the beginning
lastPos = startPos;
//we'll keep track of direction as a signed integer.
// -1 is left, 1 is right and 0 is staying still
direction = 0;
//now we clean off the transtions
if(slideData[currentSlide] && slideData[currentSlide].node){
cleanTransitions(slideData[currentSlide].node);
}
if(slideData[currentSlide + 1] && slideData[currentSlide + 1].node){
cleanTransitions(slideData[currentSlide + 1].node);
}
if(slideData[currentSlide - 1] && slideData[currentSlide -1].node){
cleanTransitions(slideData[currentSlide -1].node);
}
} else if(e.type == 'touchmove' ) {
In the touchmove find out how much the touch has moved along clientX, and then move the current slide the same amount. If the slide is moving to the left you also move the next slide, if it is moving to the right, move the previous slide instead. That way you only ever move two nodes, but you give the illusion that a whole strip is moving. Having a map to the slide information allows you to do all of this without doing any more DOM reads, only writes.
}else if(e.type == 'touchmove'){
e.preventDefault();
//figure out the direction
if(lastPos > startPos){
direction = -1;
}else{
direction = 1;
}
//make sure the slide exists
if(slideData[currentSlide]){
//move the current slide into position
setPosition(slideData[currentSlide].node, e.touches[0].clientX - startPos);
//make sure the next or previous slide exits
if(direction !== 0 && slideData[currentSlide + direction]){
//move the next or previous slide.
if(direction < 0){
//I want to move the next slide into the right position, which is the same as the
//current slide, minus the width of the viewport (each slide is as wide as the viewport)
setPosition(slideData[currentSlide + direction].node, (e.touches[0].clientX - startPos) - boundingBox[0]);
}else if(direction > 0){
setPosition(slideData[currentSlide + direction].node, (e.touches[0].clientX - startPos) + boundingBox[0]);
}
}
}
//save the last position, we need it for touch end
lastPos = e.touches[0].clientX;
}else if(e.type == 'touchend'){
When the touch ends decide whether to advance, go back, or do nothing. If nothing the slides need to 'snap back' into position, giving the user feedback as to why the slide didn't change.
}else if(e.type == 'touchend'){
//figure out if we have moved left or right beyond a threshold
//(50 pixels in this case)
if(lastPos - startPos > 50){
goTo(currentSlide-1);
} else if(lastPos - startPos < -50){
goTo(currentSlide+1);
}else{
//we are not advancing, so we need to "snap back" to the previous position
addTransitions(slideData[currentSlide].node);
setPosition(slideData[currentSlide].node, 0);
if(slideData[currentSlide + 1] && slideData[currentSlide + 1].node){
addTransitions(slideData[currentSlide + 1]);
setPosition(slideData[currentSlide + 1].node, boundingBox[0]);
}
if(slideData[currentSlide - 1] && slideData[currentSlide - 1].node){
addTransitions(slideData[currentSlide - 1]);
setPosition(slideData[currentSlide - 1].node, 0 - boundingBox[0]);
}
}
}
Now the basics are in place. You have a simple touch lightbox!
Stephen Woods has been building web applications for the better part of a decade. He currently lives in San Francisco and works as a frontend engineer at Flickr.
Thank you for reading 5 articles this month* Join now for unlimited access
Enjoy your first month for just £1 / $1 / €1
*Read 5 free articles per month without a subscription
Join now for unlimited access
Try first month for just £1 / $1 / €1
The Creative Bloq team is made up of a group of design fans, and has changed and evolved since Creative Bloq began back in 2012. The current website team consists of eight full-time members of staff: Editor Georgia Coggan, Deputy Editor Rosie Hilder, Ecommerce Editor Beren Neale, Senior News Editor Daniel Piper, Editor, Digital Art and 3D Ian Dean, Tech Reviews Editor Erlingur Einarsson and Ecommerce Writer Beth Nicholls and Staff Writer Natalie Fear, as well as a roster of freelancers from around the world. The 3D World and ImagineFX magazine teams also pitch in, ensuring that content from 3D World and ImagineFX is represented on Creative Bloq.