Animating Morphing Devices Using Snap SVG

Snap SVG logo.

Background

Before I ventured on this mini project, I had no previous experience with animating SVG graphics. This article documents my journey and details my processes (and mistakes made along the way).

The final product I created consists of a slideshow whereby four devices – desktop, laptop, tablet, and smartphone (basically iMac, MacBook Pro, iPad, and iPhone-esque form factors) – morph into one another, in order to showcase how the responsive websites I’ve created react to the various screen sizes.

The finished article, showing each of the four morphing devices.
The finished article, showing each of the four morphing devices.

Inspiration

I love Mary Lou’s work over at tympanus.net and was really inspired by this post on morphing devices, with particular interest shown to a line towards the end of the article:

The better technology to choose for this kind of effect is undoubtedly SVG, but for practicality reasons we started this experiment with CSS and JS.

I too had messed around with creating devices with CSS, but had come across two fundamental problems:

  1. Using empty <div>s to create the device shapes makes the whole thing non-semantic. I don’t like extraneous markup, so this was a big turn-off for me.
  2. It’s very difficult to make the devices truly responsive, as using percentages for measurements inevitably adds rounding errors to the calculations, and introduces gaps between shapes when scaling. The Codrops version employs adaptive design, opting to use various fixed breakpoints instead.

This got me thinking about accessibility and compatibility (the Codrops demo uses oodles of CSS3 declarations to create the devices, so isn’t IE8-friendly), and seeing as I’d only previously used SVG for static graphics, I decided to give myself a challenge.

Artistic Vision

Example of a device from the Codrops example, created with CSS.
Example of a device from the Codrops example, created with CSS.

The raison d’être of this exercise was to showcase my web development work, so I wanted to keep the design of the devices fairly simple. Whereas the Codrops devices are composed of solid blocks of colour to create the various shapes, I opted to use a more ‘schematic’ approach, composed of strokes only. This allowed the website screenshots to command attention. It was an Awwwards post that would become the inspiration for the general art direction.

Technical Vision

For this to work fluently, and include the functionality users would expect, it needed to exhibit the following behaviours:

  • Devices are to morph into one another automatically every 5 seconds unless interrupted by the user.
  • A timer will visualise the time remaining until the next transition.
  • Thumbnails above the devices will act as a marker for the current active device.
  • Thumbnails will also act as buttons which will pause the slideshow and display the relevant device when clicked.
  • Play/pause functionality will allow the user to pause and resume the slideshow.

Attempt One – CSS Transitions

My first attempt was a bit of a shot in the dark. The intention was to draw the first device using Adobe Illustrator, edit the outputted SVG so that each element was ID’d, then use CSS transforms to define the other devices with the help of a little JavaScript. I quickly learned that while CSS is useful to change the appearance of the most basic of SVG attributes, it’s certainly not very adept for more challenging situations. Smashing Magazine has a great article on styling and animating SVG’s with CSS, and serves as great starting point. If you want to dig a little deeper however, I recommend taking a look at the SVG 1.1 spec. Mozilla also hosts a great SVG attribute reference detailing all editable attributes.

For the sake of brevity, the spec allows the following basic transforms:

With these alone it’s possible to achieve a wide range of effects. My understanding was that I could use transform to scale the various parts as needed. Unfortunately, as well as scaling shapes, transform also scales stroke widths, so an element with a 3px stroke scaled to 50% along its x axis will only have 1.5px strokes along the left and right hand side. transform: scaleX(.5); gave me the following:

Screenshot demonstrating how the CSS property 'transform: scaleX(.5)' inadvertently scales strokes.
Screenshot demonstrating how the CSS property ‘transform: scaleX(.5)’ inadvertently scales strokes.

Having hit a dead-end, it was time to try a different approach.

Attempt Two – Enter Snap SVG

Considering I was going to use JavaScript to change states, I figured I could use JS to draw the devices too. Snap SVG seemed perfect for both drawing and animating SVG graphics, and with its syntax being close to jQuery’s, it’s easy enough to pick up too.

The Need To Grid

The first requirement was to create a grid that each of the devices could sit on. As the finished product was going to be responsive, the actual size was arbitrary, but I opted for 420px by 340px grid. This I drew up on to tracing paper (yup – I went proper old-school!), using subsequent layers of tracing paper to sketch out my devices. Once I was happy with each, it was time to code.

Image demonstrating how I used layers of tracing paper to create my devices.
Image demonstrating how I used layers of tracing paper to create my devices.

Building Devices

The premise was that I’d define each of the devices, and use JavaScript to switch between them. As the transition order could be interrupted by the user, it was necessary to define every element in each device, even if it wasn’t used (there’s no home button on an iMac or MacBook Pro for example).

Step 1 – Initiate Snap And Set Up Variables

var s = Snap("#device-wrapper"),
deviceOuter,
screenOuter,
screenImageDesktop,
screenImageLaptop,
screenImageTablet,
screenImagePhone,
camera,
desktopDivider,
laptopMidDivider,
laptopMidLeft,
laptopMidRight,
deviceBaseLeft,
deviceBaseRight,
deviceBaseBottom,
mobileButton;

This simply tells the browser that #device-wrapper is the target for our SVG goodness, and sets up variables for each of the device elements. Some are common to all four devices (deviceOuter and screenOuter for example), and some unique to just the one.

Step 2 – Create Initial Device (Desktop)

deviceOuter = s.rect(0,0, 420,300, 20,20);
screenOuter = s.rect(20,20, 380,220);
camera = s.circle(210,10, 2.5);
desktopDivider = s.path("M0,260, 420,260");
laptopMidDivider = s.path("M20,300, 400,300").attr({opacity: 0});
laptopMidLeft = s.path("M20,300, 20,300").attr({opacity: 0});
laptopMidRight = s.path("M400,300, 400,300").attr({opacity: 0});
deviceBaseLeft = s.path("M165,300 Q160,340 140,340");
deviceBaseRight = s.path("M255,300 Q260,340 280,340");
deviceBaseBottom = s.path("M140,340, 280,340");
mobileButton = s.circle(210,280, 10).attr({opacity: 0});
group = s.group(deviceOuter, screenOuter, camera, desktopDivider, laptopMidDivider, laptopMidLeft, laptopMidRight, deviceBaseLeft, deviceBaseRight, deviceBaseBottom, mobileButton);
group.attr({
	stroke: '#000',
	fill: 'none',
	strokeWidth: 2
});

This basically draws a series of paths (lines) and shapes taking values from my sketches. The iMac was probably the most difficult device to draw, as it required curves for the stand. For this I used SitePoint’s SVG Quadratic Bézier Curve Example and their article on SVG quadratic curves. Notice that the mobile and laptop-specific elements also get defined, but are given an opacity value of 0. I used opacity quite liberally throughout, so elements faded smoothly between transitions, as opposed to turning on and off. Finally, the grouping of all elements makes it easy to declare one set of style rules for all, as opposed to each element individually.

Step 3 – More Devices

var makeDesktop = function() {
	deviceOuter.animate({width: 420, height: 300, rx: 20, ry: 20, transform: 'T0,0'}, 1000, mina.easeinout);
	screenOuter.animate({width: 380, height: 220, transform: 'T0,0'}, 1000, mina.easeinout);
	screenImageDesktop.animate({width: 380, height: 220, transform: 'T0,0', opacity: 1}, 1000, mina.easeinout);
	screenImageLaptop.animate({width: 380, height: 220, transform: 'T0,0', opacity: 0}, 1000, mina.easeinout);
	screenImageTablet.animate({width: 380, height: 220, transform: 'T0,0', opacity: 0}, 1000, mina.easeinout);
	screenImagePhone.animate({width: 380, height: 220, transform: 'T0,0', opacity: 0}, 1000, mina.easeinout);
	camera.animate({r: 2.5, transform: 'T0,0'}, 1000, mina.easeinout);
	desktopDivider.animate({d: "M0,260, 420,260", opacity: 1}, 1000, mina.easeinout);
	laptopMidDivider.animate({d: "M20,300, 400,300", opacity: 0}, 1000, mina.easeinout);
	laptopMidLeft.animate({d: "M20,300, 20,300", opacity: 0}, 1000, mina.easeinout);
	laptopMidRight.animate({d: "M400,300, 400,300", opacity: 0}, 1000, mina.easeinout);
	deviceBaseLeft.animate({d: "M165,300 Q160,340 140,340", opacity: 1}, 1000, mina.easeinout);
	deviceBaseRight.animate({d: "M255,300 Q260,340 280,340", opacity: 1}, 1000, mina.easeinout);
	deviceBaseBottom.animate({d: "M140,340, 280,340", opacity: 1}, 1000, mina.easeinout);
	mobileButton.animate({r: 10, transform: 'T0,0', opacity: 0}, 1000, mina.easeinout);
	$("#btn-desktop").addClass("active");
	devicePosition = 0;
};
var makeLaptop = function() {
	deviceOuter.animate({width: 320, height: 210, rx: 10, ry: 10, transform: 'T50,60'}, 1000, mina.easeinout);
	screenOuter.animate({width: 300, height: 190, transform: 'T40,50'}, 1000, mina.easeinout);
	screenImageDesktop.animate({width: 300, height: 190, transform: 'T40,50', opacity: 0}, 1000, mina.easeinout);
	screenImageLaptop.animate({width: 300, height: 190, transform: 'T40,50', opacity: 1}, 1000, mina.easeinout);
	screenImageTablet.animate({width: 300, height: 190, transform: 'T40,50', opacity: 0}, 1000, mina.easeinout);
	screenImagePhone.animate({width: 300, height: 190, transform: 'T40,50', opacity: 0}, 1000, mina.easeinout);
	camera.animate({r: 1.5, transform: 'T0,55'}, 1000, mina.easeinout);
	desktopDivider.animate({d: "M30,270, 390,270", opacity: 1}, 1000, mina.easeinout);
	laptopMidDivider.animate({d: "M30,275, 390,275", opacity: 1}, 1000, mina.easeinout);
	laptopMidLeft.animate({d: "M30,270, 30,275", opacity: 1}, 1000, mina.easeinout);
	laptopMidRight.animate({d: "M390,270, 390,275", opacity: 1}, 1000, mina.easeinout);
	deviceBaseLeft.animate({d: "M30,275 Q47.5,280 50,280", opacity: 1}, 1000, mina.easeinout);
	deviceBaseRight.animate({d: "M390,275 Q372.5,280 370,280", opacity: 1}, 1000, mina.easeinout);
	deviceBaseBottom.animate({d: "M50,280, 370,280", opacity: 1}, 1000, mina.easeinout);
	mobileButton.animate({r: 1.5, transform: 'T0,-15', opacity: 0}, 1000, mina.easeinout);
	$("#btn-laptop").addClass("active");
	devicePosition = 1;
};
var makeTablet = function() {
	deviceOuter.animate({width: 180, height: 260, rx: 20, ry: 20, transform: 'T120,40'}, 1000, mina.easeinout);
	screenOuter.animate({width: 160, height: 220, transform: 'T110,40'}, 1000, mina.easeinout);
	screenImageDesktop.animate({width: 160, height: 220, transform: 'T110,40', opacity: 0}, 1000, mina.easeinout);
	screenImageLaptop.animate({width: 160, height: 220, transform: 'T110,40', opacity: 0}, 1000, mina.easeinout);
	screenImageTablet.animate({width: 160, height: 220, transform: 'T110,40', opacity: 1}, 1000, mina.easeinout);
	screenImagePhone.animate({width: 160, height: 220, transform: 'T110,40', opacity: 0}, 1000, mina.easeinout);
	camera.animate({r: 2.5, transform: 'T0,40'}, 1000, mina.easeinout);
	desktopDivider.animate({d: "M130,300, 290,300", opacity: 0}, 1000, mina.easeinout);
	laptopMidDivider.animate({d: "M130,300, 290,300", opacity: 0}, 1000, mina.easeinout);
	laptopMidLeft.animate({d: "M130,300, 130,300", opacity: 0}, 1000, mina.easeinout);
	laptopMidRight.animate({d: "M290,300, 290,300", opacity: 0}, 1000, mina.easeinout);
	deviceBaseLeft.animate({d: "M140,300 Q175,300 210,300", opacity: 0}, 1000, mina.easeinout);
	deviceBaseRight.animate({d: "M280,300 Q245,300 210,300", opacity: 0}, 1000, mina.easeinout);
	deviceBaseBottom.animate({d: "M130,300, 290,300", opacity: 0}, 1000, mina.easeinout);
	mobileButton.animate({r: 5, transform: 'T0,10', opacity: 1}, 1000, mina.easeinout);
	$("#btn-tablet").addClass("active");
	devicePosition = 2;
};
var makePhone = function() {
	deviceOuter.animate({width: 80, height: 150, rx: 10, ry: 10, transform: 'T170,90'}, 1000, mina.easeinout);
	screenOuter.animate({width: 70, height: 120, transform: 'T155,85'}, 1000, mina.easeinout);
	screenImageDesktop.animate({width: 70, height: 120, transform: 'T155,85', opacity: 0}, 1000, mina.easeinout);
	screenImageLaptop.animate({width: 70, height: 120, transform: 'T155,85', opacity: 0}, 1000, mina.easeinout);
	screenImageTablet.animate({width: 70, height: 120, transform: 'T155,85', opacity: 0}, 1000, mina.easeinout);
	screenImagePhone.animate({width: 70, height: 120, transform: 'T155,85', opacity: 1}, 1000, mina.easeinout);
	camera.animate({r: 1.5, transform: 'T0,87.5'}, 1000, mina.easeinout);
	desktopDivider.animate({d: "M180,240, 240,240", opacity: 0}, 1000, mina.easeinout);
	laptopMidDivider.animate({d: "M180,240, 240,240", opacity: 0}, 1000, mina.easeinout);
	laptopMidLeft.animate({d: "M180,240, 180,240", opacity: 0}, 1000, mina.easeinout);
	laptopMidRight.animate({d: "M240,240, 240,240", opacity: 0}, 1000, mina.easeinout);
	deviceBaseLeft.animate({d: "M180,240 Q195,240 210,240", opacity: 0}, 1000, mina.easeinout);
	deviceBaseRight.animate({d: "M240,240 Q235,240 210,240", opacity: 0}, 1000, mina.easeinout);
	deviceBaseBottom.animate({d: "M180,240, 240,240", opacity: 0}, 1000, mina.easeinout);
	mobileButton.animate({r: 3.5, transform: 'T0,-47.5', opacity: 1}, 1000, mina.easeinout);
	$("#btn-phone").addClass("active");
	devicePosition = 3;
};

Here you can see that each device transition is wrapped in a function. This allowed my to call each device in turn having added them to an array using setInterval. Each function also contained the switch to change the active thumbnail, and update the array position.

Further to this, I also created click handlers on the thumbnails which paused the array loop, and ran the appropriate device function. The timer was a completely separate function, relying on CSS transitions to animate a bar from 0% width up to 100% of its parent’s container. As an interesting caveat, if accessed from my website’s home page, the graphics would be called by AJAX in a modal window. Because of this, I also had to ensure that once the modal was closed, all timers were destroyed. If not, the complete animation would fire twice on opening a second graphic, and three times on opening a third etc. In order to achieve this, I had to use Mutation Observers (along with a suitable polyfill for IE10 and below), in order to watch for the appropriate changes to the DOM.

Finishing Off

What I really love about the end result is that it’s truly responsive, so looks great on any screen. I’ve gained a lot of respect for Dmitry Baranovskiy (also author of Snap’s predecessor Raphaël) for making such a great library, and generally making animating SVG a breeze. The transitions are fluid in all browsers apart from the latest version of Chrome (43 at the time of writing – strange as previous version were much smoother), and above all, I’ve learnt a heck of a lot along the way!

Taking It Further

On my initial research phase I also happened across Presento, a slightly different screenshot showcase whereby the screenshot is full-height and scrolls within its device container. I’d definitely like to incorporate this functionality at some point, as it’d showcase more of my work (not just the top 400 pixels or so as with the current mobile screenshots).

Leave a Reply

Comments will be approved pending moderation.