A tutorial on how to create an intro animation where a decorative Polaroid stack becomes a grid similar to the effect seen on the takeit website.

Today we’d like to show you how to create a very simple intro effect similar to the one seen on the takeit website where a stack of Polaroids gets animated to a grid when clicking on a button or when scrolling. On their site, the grid continues to get animated throughout the page but we want to show you in detail on how to achieve that one specific Polaroid-to-grid effect with all its trickiness.

So, let’s get started!

Planning the effect

Check out the demo and get familiar with all the elements present and with the effect that happens if you click the arrow button on the phone or if you scroll.

We have an intro “page” that contains a big headline, the Polaroid stack and a device with a button. We also have a header with some links. What we want to do is to animate the Polaroid stack to a grid layout and make all the other elements, except the header links, disappear with a neat animation. This will be triggered either by scrolling down or by clicking the arrow.

As with many effects, the best way is to think of the end state as default state. So, we won’t take a stack of Polaroids and calculate their positions in the final grid state but instead the grid state is their default state and the initial scattered position is something we set them to in the beginning. We’ll use the first six images and move them to a common center point and then scatter them a bit with the help of a factor that adds the illusion of randomness. Do we need to move the images up to some previous section? No, actually we don’t. We can use a fixed intro section that we’ll instead animate up, outside of the viewport, giving the illusion as if we are moving (the grid) down.

Since we will have many animations happening, we want to have solid, individual control over them. A library like dynamic.js will help us animate the elements, so we will control the crucial animations in our script.

Most of the layout will be powered by flexbox. Read this guide by Sara Soueidan from the CSS Reference to understand all its great properties.

The images we are going to use in the demo are from Unsplash.com.

Let’s start writing the markup!

Attention: We’ll be using some modern properties that are only supported in modern browsers.

The Markup

How are we going to make our effect look as if we are scrolling down the page and moving around the elements without actually scrolling? We can simply trick the user’s eye by moving a fixed background outside of the viewport, creating the illusion that we moved down in the document. For that we will create a section with the class page and the “modifier” page–mover. Inside we’ll only have a loader. Check out more CSS loaders on Load Awesome by Daniel Cardoso.

The main title will not go inside of that page section because we want to animate it differently. If we’d put it inside we’d have to consider the parent’s movement and we don’t want that. So we create a own division for the main title with the class title-wrap.

Then we define the section for the grid which will also be a page, but with the modifier page static. There we’ll also have a title wrapper and an unordered list as our grid. The last element in our static page is a button for loading more items (this we won’t implement but it’s ready for a dynamic solution).

The last two elements in our view container are the device and the arrow button to trigger the animation and show the grid.

<div class="view"> <header class="header"> <!-- some links --> </header> <section class="page page--mover"> <div class="la-square-loader"><div></div></div> </section> <div class="title-wrap"> <h1 class="title title--main">OH<em>!</em>SNAP</h1> <p class="title title--sub">Smart Auto-Filtering for your shots.</p> </div> <section class="page page--static"> <div class="page__title"> <h2 class="page__title-main">Polaroid Stack to Grid Intro Animation</h2> <p class="page__title-sub">Recreating the effect seen on the <a href="http://www.takeitapp.co/en">takeit</a> website</p> </div> <ul class="grid"> <li class="grid__item"> <a class="grid__link" href="#"> <img class="grid__img" src="img/photos/1.jpg" alt="Some image" /> <h3 class="grid__item-title">Natural saturation effects</h3> </a> </li> <li class="grid__item"><!-- ... --></li> <li class="grid__item"><!-- ... --></li> <li class="grid__item"><!-- ... --></li> <!-- ... --> </ul> <button class="button button--load" aria-label="Load more images"> <svg class="polaroid" width="100%" height="100%" viewBox="0 0 220 243" preserveAspectRatio="xMidYMid meet"> <rect class="polaroid__base" x="0" y="0" width="220" height="243" rx="5"></rect> <rect class="polaroid__inner" x="16" y="20" width="189" height="149"></rect> <g class="polaroid__loader"> <circle cx="61.5" cy="94.5" r="17.5"></circle> <circle cx="110.5" cy="94.5" r="17.5"></circle> <circle cx="159.5" cy="94.5" r="17.5"></circle> </g> </svg> <span class="button__text">Load more</span> </button> </section> <div class="device"> <div class="device__screen"></div> </div> <button id="showgrid" class="button button--view" aria-label="Show me more"> <svg width="100%" height="100%" viewBox="0 0 310 177" preserveAspectRatio="xMidYMid meet"> <path fill="#FFFFFF" d="M159.875,174.481L306.945,27.41c2.93-2.929,2.93-7.678,0-10.606L292.803,2.661c-1.406-1.407-3.314-2.197-5.303-2.197c-1.989,0-3.896,0.79-5.303,2.197L154.572,130.287L26.946,2.661c-1.406-1.407-3.314-2.197-5.303-2.197c-1.989,0-3.897,0.79-5.303,2.197L2.197,16.804C0.733,18.269,0,20.188,0,22.107s0.732,3.839,2.197,5.303l147.071,147.071C152.197,177.411,156.945,177.411,159.875,174.481L159.875,174.481z" /> </svg> </button> </div><!-- /view -->

That’s all the markup, let’s move on to the styles!

The CSS

Note that we use the necessary -webkit- prefixes for better mobile support and Safari.

First, we’ll reset the box-sizing for all elements to border-box:

*, *::after, *::before { box-sizing: border-box; }

For the body, we’ll set some typography and colors:

body { font-family: 'Avenir Next', Avenir, 'Helvetica Neue', Helvetica, Arial, sans-serif; overflow-x: hidden; color: #a5aeb5; background: #e9ecef; }

We will need a helper class that prohibits initial scrolling (while the JavaScript is still loading; then we take care of it in our script):

.js body { overflow: hidden; } .js body.overflow { overflow: auto; }

The header element with the links will be positioned absolutely and we’ll use flexbox to lay out the elements inside:

.header { position: absolute; z-index: 1000; display: -webkit-flex; display: flex; -webkit-justify-content: space-between; justify-content: space-between; -webkit-align-items: flex-start; align-items: flex-start; width: 100%; padding: 2.5em; pointer-events: none; }

With pointer-events set to none you don’t make this element clickable. Inner elements like links will have pointer-events set to auto. This technique can be useful for fixed or absolute elements that should be on top of everything but that are not obtrusive in the parts that are not “needed” to be clickable.

Now, let’s define the styles for the movable page. Since this is going to be our illusion artist, we set it to a fixed position and size it to the whole screen. The background is set to a dark color:

.page--mover { position: fixed; width: 100%; height: 100vh; pointer-events: none; background: #2d323c; }

The title wrap will be positioned at the top part of the page:

.title-wrap { position: absolute; z-index: 101; width: 100%; margin: 10vh 0 0 0; text-align: center; pointer-events: none; }

The typographic styles for the inner elements are as follows:

.title { line-height: 1; position: relative; text-indent: 0.2em; letter-spacing: 0.2em; text-transform: uppercase; } .title--main { font-size: 5.75em; margin: 0 auto; color: #df2d70; } .title--sub { font-size: 1.15em; font-weight: 700; display: block; margin: 0; color: #565f73; }

With a media query for larger screens, we ensure that the title elements are sized relatively to the viewport:

@media screen and (min-width: 100em) { .title--main { font-size: 7vw; } .title--sub { font-size: 1.35vw; } }

The static page will have a flexbox column layout that makes sure that everything is neatly centered. We’ll add a maximium allowed width which will be enough space for three full images in a row:

.page--static { display: -webkit-flex; display: flex; -webkit-flex-direction: column; flex-direction: column; -webkit-align-items: center; align-items: center; max-width: 1220px; margin: 0 auto; padding: 2em 0 0; text-align: center; }

The page title elements have the following styles:

.page__title { padding: 0 2em; } .page__title-main { font-size: 2em; margin: 0 auto; padding: 3em 0 0; color: #03a9f4; } .page__title-sub { font-size: 1.05em; margin: 0.5em 0 4em; }

Now, let’s style the device. We get the Sketch device from the Facebook devices collection and export the SVG. That will serve as the background image of our device division. This division is fixed with a z-index that will lay it on top of everything. We set the dimension to a square that is relative to the viewport height. This ensures that it never becomes bigger than half of the screen. We center it with the negative margin technique:

.device { position: fixed; z-index: 1000; bottom: 0; left: 50%; width: 45vh; height: 45vh; margin: 0 0 0 -22.5vh; background: url(../img/device.svg) no-repeat 50% 0%; background-size: 100%; }

The screen of the device will contain an app screenshot and its sizes are percentage based, relative to the device:

.device__screen { position: absolute; top: 25.5%; left: 8.5%; width: 83%; height: 100%; background: url(../img/screen.jpg) no-repeat 50% 0%; background-size: 100%; }

Next, let’s style the two buttons, the arrow button and the “load more” button. Since they both have some style resets in common, we define the following styles:

.button { margin: 0; padding: 0; border: none; background: none; }

The view button will be positioned absolutely and we’ll give it a little movement so that it stands out as trigger. We’ll also add some hover transition for the fill:

.button--view { position: absolute; z-index: 1000; top: 100vh; left: 50%; width: 3em; height: 1.75em; margin: -3em 0 0 -1.5em; -webkit-animation: pointDown 0.6s 0.6s ease-in infinite alternate forwards; animation: pointDown 0.6s 0.6s ease-in infinite alternate forwards; fill: #fff; } @-webkit-keyframes pointDown { from { -webkit-transform: translate3d(0,-10px,0); transform: translate3d(0,-10px,0); } to { -webkit-transform: translate3d(0,0,0); transform: translate3d(0,0,0); } } @keyframes pointDown { from { -webkit-transform: translate3d(0,-10px,0); transform: translate3d(0,-10px,0); } to { -webkit-transform: translate3d(0,0,0); transform: translate3d(0,0,0); } } .button--view path { -webkit-transition: fill 0.3s; transition: fill 0.3s; fill: inherit; } .button--view:hover, .button--view:focus { outline: none; fill: #03a9f4; }

The load more button also has some text:

.button--load { width: 7em; color: #a3b0bd; } .button--load svg { width: 2.5em; } .button__text { font-size: 0.65em; font-weight: bold; display: block; margin: 0.85em 0 0 0; text-indent: 3px; letter-spacing: 3px; text-transform: uppercase; color: inherit; -webkit-transition: color 0.3s; transition: color 0.3s; }

Besides a hover transition, the little Polaroid will be ready for a loading state that we trigger with the class button–loading. The three little circles will be animated to indicated that images are being loaded:

.polaroid__base, .polaroid__loader { -webkit-transition: fill 0.3s; transition: fill 0.3s; fill: #a3b0bd; } .button--load:hover, .button--load:focus { color: #03a9f4; outline: none; } .button:hover .polaroid__base, .button:focus .polaroid__base, .button:hover .polaroid__loader, .button:focus .polaroid__loader { fill: #03a9f4; } .polaroid__inner { fill: #e9ecef; } .button--loading .polaroid__loader circle { -webkit-animation: fadeInOut 0.3s ease-in infinite alternate forwards; animation: fadeInOut 0.3s ease-in infinite alternate forwards; } .button--loading .polaroid__loader circle:nth-child(2) { -webkit-animation-delay: 0.1s; animation-delay: 0.1s; } .button--loading .polaroid__loader circle:nth-child(3) { -webkit-animation-delay: 0.2s; animation-delay: 0.2s; } @-webkit-keyframes fadeInOut { from { opacity: 0; } to { opacity: 1; } } @keyframes fadeInOut { from { opacity: 0; } to { opacity: 1; } }

Then we will want to hide the button (that’s for when there are no more images to be loaded in your real case scenario):

.button--hidden { pointer-events: none; opacity: 0; -webkit-transition: opacity 0.3s; transition: opacity 0.3s; }

The grid will also be powered by flexbox. The z-index is 100 just so that its underneath the device and the main title:

.grid { position: relative; z-index: 100; display: -webkit-flex; display: flex; -webkit-flex-wrap: wrap; flex-wrap: wrap; -webkit-justify-content: center; justify-content: center; -webkit-align-items: center; align-items: center; max-width: 100%; margin: 0 auto; padding: 0 0 6em; list-style: none; }

We want to control the size of our grid items so they don’t need to be flexible. The width should be one third of the parent’s width. A padding of 10px will create a gutter for the Polaroid images:

.grid__item { display: block; -webkit-flex: none; flex: none; width: 33.33%; padding: 10px; }

When we insert new items into the grid, we need to control the visibility of the grid items. For this we will create the following helper class:

.grid__item--hidden { opacity: 0; }

The link element will be the one with the border decoration, so we add a padding of 13px for all sides except for the bottom side. This part will be styled in the title element.

.grid__link { display: block; padding: 13px 13px 0 13px; background: #fff; }

The image will fill all available space:

.grid__img { display: block; width: 100%; }

The Polaroid titles will have a nice script font to look like they have been written on:

.grid__item-title { font-family: 'Caveat', cursive; line-height: 1; position: relative; overflow: hidden; margin: 0; padding: 1em 0.5em; text-align: left; white-space: nowrap; text-overflow: ellipsis; color: #698b8d; background: #fff; }

Note that we have inserted the link to the Google Web font in the head of our HTML.

Since we need to preload our images, let’s show a little animated loader that actually reminds of a Polaroid. We’ve used a CSS loader from Load Awesome and adjusted some styles:

/* Loader (styles defined in square-loader.min.css) */ .la-square-loader { position: absolute; top: 60%; left: 50%; color: #565f73; opacity: 0; -webkit-transform: translate3d(-50%,-50%,0) scale3d(1.35,1.35,1); transform: translate3d(-50%,-50%,0) scale3d(1.35,1.35,1); } .la-square-loader > div { border-radius: 2px; }

We should never hide anything assuming that JS is available but think about how everything looks/works without it. So, while we are actually preloading the images, we don’t want some elements to be shown. Then, when the images are loaded, we’ll want to show the grid, the device and the arrow button:

.js .la-square-loader { opacity: 1; -webkit-transition: opacity 0.3s; transition: opacity 0.3s; } .view--loaded .la-square-loader { opacity: 0; } .js .grid, .js .device, .js .button--view { opacity: 0; } .view--loaded .grid, .view--loaded .device, .view--loaded .button--view { opacity: 1; } .view--loaded .button--view { -webkit-transition: opacity 0.3s; transition: opacity 0.3s; }

The same holds for the pointer events of the static page:

.js .page--static { pointer-events: none; } .view--grid .page--static { pointer-events: auto; }

Finally, we have to tweak our layout a bit for smaller screens:

@media screen and (max-width: 56em) { .header { padding: 1em; } .title-wrap { font-size: 53%; margin: 85px 0 0 0; } .page__title-main { font-size: 1.3em; } .page__title-sub { margin-bottom: 1em; } .grid__item { width: 50%; max-width: none; } }

And that are all the styles!

Now, let’s write the magic spells for this dead bird to come to life!

The JavaScript

First, let’s define and initialize some variables:

// main page container var mainContainer = document.querySelector('.view'), // the grid element gridEl = mainContainer.querySelector('.grid'), // grid items gridItems = [].slice.call(gridEl.querySelectorAll('.grid__item')), // main title element titleEl = mainContainer.querySelector('.title-wrap > .title--main'), // main subtitle element subtitleEl = mainContainer.querySelector('.title-wrap > .title--sub'), // the fullscreen element/division that will slide up, giving the illusion the items will fall down pagemover = mainContainer.querySelector('.page--mover'), // the loading element shown while the images are loaded loadingStatusEl = pagemover.querySelector('.la-square-loader'), // window sizes (width and height) winsize = {width: window.innerWidth, height: window.innerHeight}, // translation values (x and y): percentages of the item´s width and height; scale value; rotation (z) value // these are the values that the 6 initial images will have introPositions = [ {tx: -.6, ty:-.3, s:1.1, r:-20}, {tx: .2, ty:-.7, s:1.4, r:1}, {tx: .5, ty:-.5, s:1.3, r:15}, {tx: -.2, ty:-.4, s:1.4, r:-17}, {tx: -.15, ty:-.4, s:1.2, r:-5}, {tx: .7, ty:-.2, s:1.1, r:15} ], // the device deviceEl = mainContainer.querySelector('.device'), // the animated button that triggers the effect when clicked showGridCtrl = document.getElementById('showgrid'), // the title and subtitle shown on top of the grid pageTitleEl = mainContainer.querySelector('.page__title > .page__title-main'), pageSubTitleEl = mainContainer.querySelector('.page__title > .page__title-sub'), // the grid´s load more button loadMoreCtrl = mainContainer.querySelector('button.button--load'), // true if the animation is currently running isAnimating, // true if the user scrolls (rather than clicking the down arrow) scrolled, // current view: stack | grid view = 'stack';

introPositions is the array where we can define the positions of each of the six images that are part of the stack. Initially, the images will be positioned on the center of the screen (at the bottom) and then each one will have a specific transform applied based on introPositions. The “tx” and “ty” values are percentage based and define how much of the item’s width/height will be added to the item’s translation. The “s” and “r” represent the scale and rotationZ respectively. If we’d want the stack to appear differently we’ll just need to adjust these array values.

Next, let’s define our init function:

function init() { // disable scroll while loading images classie.add(document.body, 'overflow'); disableScroll(); // preload images imagesLoaded(gridEl, function() { // enable page scroll again enableScroll(); // controls the visibility of the grid items. Adding this class will make them visible. classie.add(mainContainer, 'view--loaded'); // show initial view showIntro(); // bind events initEvents(); }); }

We’ll preload all the images inside the grid, and only then show the stack of the six first images behind the device. While the images are being loaded we disallow the user to scroll the page, since the act of scrolling will also make the grid appear.

The showIntro function will position the six images behind the device. The images are first positioned in the center of the page (bottom) and the translation, scale and rotation values set in introPositions are applied to each image. We want that both the images and the device element slide from bottom up once all images are loaded, so we need to position these elements first and then animate them to their final state:

function showIntro() { // display the first set of 6 grid items behind the phone gridItems.slice(0,6).forEach(function(item, pos) { // first we position all the 6 items on the bottom of the page // (item´s center is positioned on the middle of the page bottom) // then we move them up and to the sides (extra values) // and also apply a scale and rotation var itemOffset = item.getBoundingClientRect(), settings = introPositions[pos], center = { x : winsize.width/2 - (itemOffset.left + item.offsetWidth/2), y : winsize.height - (itemOffset.top + item.offsetHeight/2) } // first position the items behind the phone dynamics.css(item, { opacity: 1, translateX: center.x, translateY: center.y, scale: 0.5 }); // now animate each item to its final position dynamics.animate(item, { translateX: center.x + settings.tx*item.offsetWidth, translateY: center.y + settings.ty*item.offsetWidth, scale : settings.s, rotateZ: settings.r }, { type: dynamics.bezier, points: [{"x":0,"y":0,"cp":[{"x":0.2,"y":1}]},{"x":1,"y":1,"cp":[{"x":0.3,"y":1}]}], duration: 1000, delay: pos * 80 }); }); // also animate/slide the device in: // first, push it slightly down; to make it disappear completely outside // of the viewport we´d need to set the translateY to // winsize.height * 0.45 --> 45vh) dynamics.css(deviceEl, { translateY: winsize.height * 0.25 } ); // now animate it up dynamics.animate(deviceEl, { translateY: 0 }, { type: dynamics.bezier, points: [{"x":0,"y":0,"cp":[{"x":0.2,"y":1}]},{"x":1,"y":1,"cp":[{"x":0.3,"y":1}]}], duration: 1000 }); }

The bind/init events function looks as follows:

function initEvents() { // show the grid when the showGridCtrl is clicked showGridCtrl.addEventListener('click', showGrid); // show the grid when the user scrolls the page var scrollfn = function() { scrolled = true; showGrid(); window.removeEventListener('scroll', scrollfn); }; window.addEventListener('scroll', scrollfn); // show/load more grid items; // this is just a simple dummy function that // simulates the loading of more items into the grid loadMoreCtrl.addEventListener('click', loadNextItems); // window resize: recalculate window sizes and reposition // the 6 grid items behind the device (if the grid view is not yet shown) window.addEventListener('resize', debounce(function(ev) { // reset window sizes winsize = {width: window.innerWidth, height: window.innerHeight}; if( view === 'stack' ) { gridItems.slice(0,6).forEach(function(item, pos) { // first reset all items dynamics.css(item, { scale: 1, translateX: 0, translateY: 0, rotateZ: 0 }); // now, recalculate.. var itemOffset = item.getBoundingClientRect(), settings = introPositions[pos]; dynamics.css(item, { translateX: winsize.width/2 - (itemOffset.left + item.offsetWidth/2) + settings.tx*item.offsetWidth, translateY: winsize.height - (itemOffset.top + item.offsetHeight/2) + settings.ty*item.offsetWidth, scale : settings.s, rotateZ: settings.r }); }); } }, 10)); }

We will need to define the events for when we click the “show grid” control button (arrow), the page scrolling, the window resize and the loading of more grid items.

Now let’s define all the animations we need for showing the grid. Once again, we’ll be using the dynamics.js library which will make this process much easier:

function showGrid() { // return if currently animating if( isAnimating ) return; isAnimating = true; // hide the showGrid ctrl dynamics.css(showGridCtrl, {display: 'none'}); // main title animation dynamics.animate(titleEl, { translateY: -winsize.height/2, opacity: 0 }, { type: dynamics.bezier, points: [{"x":0,"y":0,"cp":[{"x":0.7,"y":0}]},{"x":1,"y":1,"cp":[{"x":0.3,"y":1}]}], duration: 600 }); // main subtitle animation dynamics.animate(subtitleEl, { translateY: -winsize.height/2, opacity: 0 }, { type: dynamics.bezier, points: [{"x":0,"y":0,"cp":[{"x":0.7,"y":0}]},{"x":1,"y":1,"cp":[{"x":0.3,"y":1}]}], duration: 600, delay: 100 }); // device animation dynamics.animate(deviceEl, { translateY: 500, opacity: 0 }, { type: dynamics.bezier, points: [{"x":0,"y":0,"cp":[{"x":0.7,"y":0}]},{"x":1,"y":1,"cp":[{"x":0.3,"y":1}]}], duration: 600 }); // pagemover animation dynamics.animate(pagemover, { translateY: -winsize.height}, { type: dynamics.bezier, points: [{"x":0,"y":0,"cp":[{"x":0.7,"y":0}]},{"x":1,"y":1,"cp":[{"x":0.3,"y":1}]}], duration: 600, delay: scrolled ? 0 : 120, complete: function(el) { // hide the pagemover dynamics.css(el, { opacity: 0 }); // view is now ´grid´ view = 'grid'; classie.add(mainContainer, 'view--grid'); } }); // items animation gridItems.slice(0,6).forEach(function(item, pos) { dynamics.stop(item); dynamics.animate(item, { scale: 1, translateX: 0, translateY: 0, rotateZ: 0 }, { type: dynamics.easeInOut, duration: 600, delay: scrolled ? 0 : 120 }); }); // page title animation dynamics.css(pageTitleEl, { translateY: 200, opacity: 0 }); dynamics.animate(pageTitleEl, { translateY: 0, opacity: 1 }, { type: dynamics.bezier, points: [{"x":0,"y":0,"cp":[{"x":0.2,"y":1}]},{"x":1,"y":1,"cp":[{"x":0.3,"y":1}]}], duration: 800, delay: 400 }); // page subtitle animation dynamics.css(pageSubTitleEl, { translateY: 150, opacity: 0 }); dynamics.animate(pageSubTitleEl, { translateY: 0, opacity: 1 }, { type: dynamics.bezier, points: [{"x":0,"y":0,"cp":[{"x":0.2,"y":1}]},{"x":1,"y":1,"cp":[{"x":0.3,"y":1}]}], duration: 800, delay: 500 }); // the remaining grid items gridItems.slice(6).forEach(function(item) { dynamics.css(item, { scale: 0, opacity: 0 }); dynamics.animate(item, { scale: 1, opacity: 1 }, { type: dynamics.bezier, points: [{"x":0,"y":0,"cp":[{"x":0.2,"y":1}]},{"x":1,"y":1,"cp":[{"x":0.3,"y":1}]}], duration: 800, delay: randomIntFromInterval(100,400) }); }); }

We start by hiding the “show grid” control (arrow) and then we animate the following elements: the main title and subtitle, the device, the page–mover division (fullscreen element that slides up giving us the illusion the stack items will fall down), each stack item, the page title and subtitle (the title/subtitle before the grid) and all the other grid elements. Note that these animations follow a specific order; for each one we’ll need to define a specific duration and a delay. Also, if the grid is shown as a result of a page scroll then the delays can be different.

And we are in the end! Don’t forget to have a look at both demos and see how we can tune the effect of the stacked grid items moving to their grid position!

We hope you enjoyed this tutorial and find it useful!