The following is a guest post by Rajoshi Ghosh and Tanmai Gopal of 34 Cross. They emailed me to show me their new website and how performant it was despite having cool features, being visually rich, and responsive. I was like, hey, you should write about it! This is that. Bask in the math.

Editor's note: they have updated the site since this article was published. They have kept the site up that this article refers to at "old.34cross.in", and I'll change the links in this article to that.

At 34 Cross, we wanted to develop our new website to be responsive, mobile friendly, and easily load on even 2G networks using only HTML and CSS. In this article we'll tell you about the challenges we faced in both design and speed optimization of the site, and how we overcame them. First, we'll focus on the scrolling parallax effect. Next, we show some interactive features that are possible through CSS alone. Finally, we include a checklist of things to consider for performance in general.

Part 1. The CSS only parallax effect

We built our CSS parallax based on (perhaps the first) CSS parallax concept by Keith Clark. His key idea behind parallax is beautifully simple. Essentially elements that are "closer" to you move more quickly and elements "farther away" move slower as you scroll. It is a layered effect of different elements moving at different speeds.

This is unusual for a website, where normally all elements are placed on a flat plane and everything moves at the same speed when you scroll. But if we look at a website as a 3D world projected on our computer screen, we can move some stuff really far behind our screen, and let some other stuff remain in front. Scrolling vertically then makes things further away move slower and the things in front move faster. Voila, parallax! Exactly how it works in the real world.

1.1. A brief aside into CSS 3D geometry

CSS's 3D transforms allow us to look at the elements of a website like this:

The point `C` is the computer screen center, and the top-left corner of the screen (from the user's point of view) is the origin. Web elements can be positioned anywhere, and the browser rendering engine will project those elements onto our screen and let us view them. This is essentially the same thing as drawing in 3D.

The first step here is defining the perspective:

  1. Choose an element to be your 3D projection surface. It doesn't need to be the entire screen.
  2. Tell the browser how far you want the projected surface to seem to be from your eyes.

Both of these things are done by defining the perspective property on an element. The perspective property essentially defines the length `p` from the figure above (Fig 1). The lower the value of `p`, the more intense the 3D effect. The larger the value, the less intense it is.

1.2. Implementing Parallax

Let's define the element through which we'll be viewing our 3D world.

  1. We want the entire viewport to be our view into the 3D world, so we'll create a #container element that covers the whole viewport.
  2. Let's define our value of `p` to be `1px`. By default the perspective origin is the center of the element (point `C`).
  3. We'll chunk the parallax elements into groups. The group we create will have an element in the background (by moving it back along the `-z` axis) and one in the foreground.

This results in the following action:

See the Pen jEWzBL by Tanmai Gopal (@tanmaig) on CodePen.

Special points to note:

  • For reasons that we don't truly understand, we need to chunk our parallax effects into groups. Chrome doesn't really need that, but Firefox does.
  • preserve-3d is an important instruction for #group1, asking it to respect the 3d properties of its parent element. Otherwise, the perspective instructions would be ignored.

Now we have a gap of 500px from the top of the viewport in the foreground plane. The background appears smaller, because it has been pushed away. It also has some whitespace around it because of its projection (`d` in Fig 1).

The next things we want to do is make it appear to be 500px in height so that it fills up the 500px gap. From Fig 1, the projected height is `h_1`.

Basic trigonometry tells us that:

`p/(p-z) = h_1/h .`

Which gives us the projected height:

`h_1 = h * (1 + z/(p-z))`

Since we set z to -1px and p to 1px, we get:

`h_1 = h / 2`

So that would mean that the translation scales our image down by `1/2`, and so scaling up our original element by 2 would make it appear to be 500px in height.

Let's look at that in action. All the other CSS is as before.

See the Pen EaPEbG by Tanmai Gopal (@tanmaig) on CodePen.

The far away element is now 500px in height, but there still seems to be some whitespace remaining atop the element. Open the Pen above in a new tab and resize your window vertically. The whitespace on top of our pretend-foreground pastel-red-div will keep changing. Where is this whitespace coming from?

This calls for a little bit of detail into how scale works. Scale, applied on an element, scales that element from its center. Let's look at a sideways view of our elements before and after scaling:

Before scaling. A viewport at perspective p, with an elemented translated along the Z axis, by -z (behind the screen)

After the element scaled about its own center.

Going back to our HTML, we see that even though we scaled the image up so that it would be perceived at the same size, the element has a certain offset from the top. Let's try correct this by solving for the top gap `x`:

  • `h` is the height of the element in the background (`EF`)
  • `s` is the scale applied. So, `s.h = E_2F_2`
  • `v/2` is half the height of the viewport
  • `e` is the vertical distance from the perspective origin to the element center, and `e_1` its projection on the viewport
  • `1 + z/(p-z)` is the factor any height at `z` distance is multiplied with to result in the projected height.
  • Using the constraint that on the viewport projection: `x + E_2'O' + e_1 ` is half of the viewport:
  • `x + s/2 * h * (1 + z/(p-z)) + e * (1 + z/(p-z)) = v/2`
  • `x = h/2*(1-s) + h*z*(1-s)/(2*(p-z)) - v*z/(2*(p-z))`
  • Applying our properties: `z = -1, p = 1, s = 2` we get:
  • `x = (v-h)/4`

It is important to note that the gap `x` depends on the viewport height. If we offset the top of our background element by this value, the top-edge will always be aligned. But since `x` is the perceived value of the gap, the background element will move up by `(1 + z/(p-z))^-1` times that. That means we should move our element up by: `((p-z)/p) * x`, which in our case is `2 . x`.

But how do we apply this formula version of CSS height? CSS calc() to the rescue! With our element of height 500px, we get:

top: calc(250px - 50vh);

Let's see this in action:

See the Pen myPBpz by Tanmai Gopal (@tanmaig) on CodePen.

Perfect! Now we can just keep adding more groups. Within each group, we position the elements as we need. Hack around with the z-index values to make sure the right elements come out on top.

Here's a Sass module to help get you set up with the basic translate and scale math for parallax.

Part 2. Pure CSS Interactivity

2.1. Responsive menu navigation with CSS only

The site has an average links-in-a-row navigation bar when there is room for it. But on small screens, we wanted a CSS only push-down style navigation. This requires some interactivity (tap to reveal). The few HTML elements that have interactivity without any JS are form elements, like checkboxes. When these elements change state, CSS selectors allow us to detect those changes and do things.

How this works:

  1. A checkbox in the HTML: <input type="checkbox">
  2. A pseudo state selector in the CSS: input:checked
  3. A sibling selector in the CSS to select the adjacent <div>: input:checked ~ div, for toggling the height of the div on state change
  4. Use max-height to transition height of the div rather than height, because the final height is probably unknown.

The result:

See the Pen XJXqMr by Tanmai Gopal (@tanmaig) on CodePen.

You can be creative with this, and make re-usable transitions for things like "read more" accordions. The key constraint that you have to respect is that the expandee element must follow the input element for the sibling selector to work. position: absolute will be your friend if you want the trigger element (like a "read more" link or arrow button) to appear to be below the element that's getting expanded.

2.2. Animate the scroll with only CSS

This is just a trick, with a fairly big caveat.

We can only control style properties with CSS, not scrollbar position, which is only really possible with JavaScript (and user interaction). But we can use the CSS :target selector to animate the top value of the container <div> to a particular value slowly. The caveat is the user loses scroll control, since the top value has been set by CSS.

So while you can animate a scroll (as we'll show here), you might not want to do this if you think users prefer just scrolling.

How this works:

  1. A click on an anchor link appends a fragment to the URL. (e.g. http://site.com/#id)
  2. The CSS :target pseudo selector activates when the URL fragment matches that ID (e.g. #id:target)
  3. This can be used to animate the top property, creating a scroll-like effect

In the sample below, we created some in-page anchor links. Instead of typically placing the anchors near the appropriate content, we create fake anchors that help us trigger the CSS animation.

See the Pen raeGbw by Tanmai Gopal (@tanmaig) on CodePen.

Part 3. Optimization checklist

Optimizing page load speed is an endless effort. You'll need to figure out a sweet spot between how much time you can dedicate to improving your existing infrastructure, upgrading your infrastructure, and how much you need to care. There are a few obvious, but very important pointers:

  1. Reduce the amount of data transferred per request: use only minified/compressed assets.
  2. Reduce the number of requests that the browser needs to make to render a page. Every CSS file, JavaScript file, and image file is yet another request. Also use browser caching on them so they don't need to be requested multiple times.
  3. Improve your servers response time. Use nginx for static content and Apache otherwise. Tune the servers to get the maximum performance out of them. Use good application layer caching, especially if you have content that is frequently dynamically generated (say, through a complex SQL join).
  4. Reduce content blocking JavaScript.
  5. Don't write irrelevant CSS rules. Always plan to write your CSS efficiently.

3.1. Image Compression

Images are a huge contributor to slow page loads. Here's a checklist to follow to get the most out of your images:

3.1.1 Resizing images and choosing the right format

Different image formats have different advantages. Most formats are scale-sensitive like JPG, PNG and some are scale-invariant like SVG.

  1. Native image dimensions should be exactly or close to rendered image dimensions (i.e. it's best if a 400x400px JPG is displayed at 400x400). Or use a scale-invariant format like SVG.
  2. Image types:
    • Millions of colors (like a photo): JPG and lossy compression
    • Illustrations or transparency requirements: PNG8, and then if you really need more than 256 colours, PNG truecolor

3.1.2 Compressing Images

There are many ways to compress and optimize images. Here are some close-to-the-metal techniques to reduce image sizes that will help you squeeze out the last few unnecessary bytes from your images.

To compress JPGs manually, a good place to start is this StackOverflow answer. Use ImageMagick and run:

convert -strip -interlace Plane -gaussian-blur 0.05 -quality 85% source.jpg result.jpg

If you don't want blur, use:

-sampling-factor 4:2:0

Original, 27.8 KB

Optimized, 8.8 KB (with sampling factor instead of blur)

To compress PNGs manually:

  • Step 1: pngcrush tries a variety of methods to reduce the colour palette, discard useless chunks etc. Source: File size reduction - YUI blog
  • Step 2: pngquant reduces your png to a 8bit format from a PNG truecolor. This is always worth checking for static images because sizes reduce to about a third! The only caveat is that you'll have to look at the image manually and make sure that the quality is good enough.
Protip: Use progressive rendering for JPG or interlaced to get better looking image loads. See Progressive rendering on Coding Horror.

3.1.3 Sprites and Data URIs

An image sprite is a large image that contains many smaller images at different positions. They are most commonly rendered through CSS background-position shifting around to display the part you need. The important part: sprites reduce the number of overall requests to the server, which as we already covered, is good for performance.

Another advantage to spriting is that all the images are loaded, so lazy-loading problems are prevented. For example: when a hover transition displays a separate image, then there is an image flash, because the image is only fetched when required. Sprites prevent image preloading problems.

The biggest disadvantage to spriting is that it can be a pain to manage building the sprite yourself. This article has lots of techniques for that. If you have a set of similarly sized images, making a sprite is a painless task with ImageMagick's montage:

montage -mode concatenate -tile 2x10 1.jpg 2.jpg ... out.jpg

This makes a 2 column by 10 row montage of images called out.jpg.

Data URIs are a nifty technique of embedding the required images within HTML or the CSS. Like sprites, data URIs reduce the number of overall requests to the server. However, beware: data URIs are up to 6x slower on mobile.

3.2. Caching and Minification

There are enough resources on good minification and caching techniques, however, here's a dead-simple solution that is good to implement: Pagespeed's nginx-module. The pagespeed modules for Apache and nginx are a lot of best practices combined. There are a lot of filters and options available, all worth reading about in detail. Build your own nginx with the pagespeed module, or use a docker image like this.

Conclusion

  1. There's a lot you can do with just CSS. Some interactivity added with CSS is essentially a hack, but there is undeniable power in adding interaction and transitions declaratively.
  2. Always optimize your images! Use lossy compressions wherever you can and dedicate the time to get them right. Build automatic compression and resizing into your build processes.
  3. Pages should load fast! There's probably nothing as effective (for the minimal effort) as Google's pagespeed modules.