loading...
Cover image for How to create pure CSS illustrations and animate them - Part 3

How to create pure CSS illustrations and animate them - Part 3

agathacco profile image Agathe Cocco Updated on ・15 min read

This is the third part in a three-part series about CSS illustrations and animations. In this last part, we are going to build an animated lighthouse scene. For this, we'll look at new techniques such as SASS loops and CSS 3D transforms.

Part 1: Learning basics and workflow tips with a CSS Smiley Face
Part 2: Intro to CSS animations with a CSS Polaroid
Part 3: More advanced techniques with a CSS Lighthouse Scene

Here's what we'll be building:

Because this illustration is a bit more complex than the previous ones, we will go more quickly over what we've already seen. I will however introduce some new techniques like CSS 3D transforms, repeating-gradients and SASS loops.

Before we get started, let's observe what components make this illustration, and how they overlap. Our scene is composed of two main groups, the background and the lighthouse. We need to set the background at the bottom of the z pile, and the lighthouse at the top. Both of these two groups each contain many elements that also overlap, which can easily become confusing. Let's make use of the z-index property to ensure each element is at the right place in the pile.

Here's what our basic HTML/Pug tree looks like. We'll be adding a few more elements to it later on.

.scene
  .background
    .stars
    .moon
    .mountains
    .sea
      .waves
      .boat
  .lighthouse-group
    .land
    .lighthouse-holder
      .shadow
      .lighthouse
      .top
      .windows
      .door
        .stairs

In the SCSS, let's define color variables and global properties:

$x-dark: #29284c;
$dark: #4c4b82;
$medium: #717ae1;
$light: #b9befa;
$x-light: #d6d9f6;
$aqua: #75e2fa;
$grey: #9e9ebe;
$yellow: #f7f2b4;

html,
body {
  height: 100%;
  width: 100%;
  overflow: hidden;
  padding: 0;
  margin: 0;
}
html {
  box-sizing: border-box;
}
*,
*:before,
*:after {
  box-sizing: inherit;
}
* {
  position: absolute;
}
*:before,
*:after {
  content: "";
  position: absolute;
}

We'll work on the background first:

.scene {
  width: 100vw;
  height: 100vh;
}
.background {
  background: $x-dark;
  background-image: linear-gradient(
    $x-dark 0%,
    $dark 10%,
    $medium 60%,
    $aqua 90%
  );
  width: 100%;
  height: 100%;
  overflow: hidden;
  z-index: 1;
}
.sea {
  background: $x-dark;
  background-image: linear-gradient(
    to top,
    $x-dark 0%,
    $dark 30%,
    $medium 60%,
    $aqua 90%
  );
  width: 100%;
  height: 170px;
  bottom: 0;
  left: 0;
  z-index: 2;
}

The .scene element will act as our main container and we want it to be as wide and high as our screen. The .background is our first layer, and the .sea is placed on top. We are applying gradients to both elements with the background-image property. Again, in real life, we would have to use vendor prefixes for this property, but for the sake of brevity, let's omit them.

Using loops to generate and randomize content

On to the stars. We want to create about 60 stars, so we need 60 HTML elements. We also need to generate a unique position for each of these .star elements. It means 60 different CSS classes, or 60 uses of the nth-child selector. We don't really want to do this as it sounds really long and repetitive. Instead, let's us a loop.

In Pug and SCSS, just like in Javascript, loops are a powerful tool. They allow you, amongst other things, to easily and automatically generate content. Here's how it works.

First we need to create 60 .star elements with Pug. Pug loops are simply javascript loops with a slightly different syntax:

- for (var x = 0; x < 60; x++)
  .star

That's it! Only two lines of code!

Now we need to style these elements. First, we create a .stars container that will contain all the .star elements. Then we can define the common styles for all them. (we'll create the twinkle animation a bit later).

.stars {
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 1;
  .star {
    border-radius: 50%;
    background-color: $light;
    animation: twinkle 5s ease-in-out infinite;
  }
}

We still cannot see any stars in our illustration because we haven't set a position or size yet. We need these properties to be different for each star. Like we said earlier, we could target these elements one by one and manually code a different value for each, but we've already decided against that. A SASS loop is going to help us randomly generate these values instead. Here's the syntax of a basic loop:

@for $i from 1 through (60) {
 // do something
}

In order to target each element with different values, we can use the nth-child selector:

@for $i from 1 through (60) {
  .star:nth-child(#{$i}) {
    // do something
  }
}

In CSS this will compile to:

.star-nth-child(1) {
  // do something
}

.star-nth-child(2) {
  // do something
}

...

.star-nth-child(60) {
  // do something
}

And to randomize the values, we are using the SASS random function:

@for $i from 1 through (60) {
  .star:nth-of-type(#{$i}) {
    top: random(100)+vh;
    left: random(100)+vw;
    width: random(4)+px;
    height: random(4)+px;
    animation-delay: random(5)+s;
  }
}

The random function takes an integer as a parameter, and returns a random value between 1 and the passed integer. So random(100) will return a random number between 1 and 100. We then add the unit we want as a suffix. We want the stars to spread across the entire screen, so I'm using the vw/vh units for the top and left properties. It will place the stars at a random position on the screen, and because the random function is defined in the loop, it will be called for each star, generating a new position each time.

This was easy to set up, but we have an issue. If you look at the stars, you'll see that they aren't round. This is because the height and width properties have each been assign a different value that's been generated with a different instance of the random function. To fix this we need to store the result of the random function in a variable:

@for $i from 1 through (60) {
  $size: random(4)+px;
  .star:nth-of-type(#{$i}) {
    top: random(100) + vh;
    left: random(100) + vw;
    width: $size;
    height: $size;
    animation-delay: random(5) + s;
  }
}

Here we go. Our stars aren't animated yet but we'll fix this later.


Let's add some more details to our background:

The moon is created with a simple border-radius, combined with the box-shadow property to create a glow around it:

.moon {
  width: 80px;
  height: 80px;
  top: 25%;
  right: 10%;
  border-radius: 50%;
  z-index: 2;
  background-color: $x-light;
  box-shadow: 0 0 10px $x-light, 0 0 20px $x-light, 0 0 30px $x-light, 0 0 40px $x-light, 0 0 50px $aqua, 0 0 100px $x-light;
}

For the mountains, we use a Pug loop again to create four elements.

.mountains
  - for (var i = 0; i < 4; i++)
    .mountain 

Each mountain is essentially a square that's been rotated by 45 degrees, and half hidden behind the .sea element. Then for each, we use the :after selector to add a gradient. We can give each of them a different position and size using the nth-child selector.

.mountains {
  width: 100%;
  height: 250px;
  bottom: 65px;
  z-index: 3;
  .mountain {
    width: 250px;
    height: 250px;
    background-color: $medium;
    right: 50px;
    bottom: -40px;
    transform: rotate(45deg);
    border-radius: 3px;
    &:after {
      width: 100%;
      height: 100%;
      opacity: 0.7;
      background-image: linear-gradient(135deg, $dark 0%, $medium 20%, $aqua 40%);
    }
  }
  .mountain:nth-child(2) {
    right: 220px;
    width: 240px;
    height: 240px;
    z-index: 2;
  }
  .mountain:nth-child(3) {
    right: 350px;
    width: 260px;
    height: 260px;
  }
  .mountain:nth-child(4) {
    right: 130px;
    width: 200px;
    height: 200px;
    z-index: 3;
    bottom: -70px;
    &:after {
      background-image: linear-gradient(135deg, $dark 0%, $medium 6%, $aqua 20%);
    }
  }
}

To add waves in the sea, I'm using Pug and SASS loops again:

.sea
  - for (var i = 0; i < 30; i++)
    .wave
.wave {
  background-color: $x-light;
  height:3px;
  border-radius: 100%;
  opacity: 0.2;
  animation: wave 5s linear infinite;
}
@for $i from 1 through (30) {
  $size: random(100) + 50px;
  .wave:nth-of-type(#{$i}) {
    bottom: random(170) + px;
    left: random(100) + vw;
    width: $size;
    opacity: random (5) * 0.1;
    animation-delay: random(3) + s;
  }
}

The last element in the background is the boat. It is composed of three main parts: a base, and two sails. To create the base and the triangular shapes of the sails, let's use the clip-path property. This tool is great for creating clip-path shapes on the fly.

.boat {
  width: 90px;
  height: 90px;
  bottom: 90px;
  .base {
    width: 110px;
    height: 25px;
    bottom: -20px;
    clip-path: polygon(0 0, 100% 0, 100% 100%, 20% 100%);
    background-color: $dark;
  }
  .sail:nth-child(1) {
    width: 90px;
    height: 80px;
    left: 5px;
    clip-path: polygon(50% 0%, 0% 100%, 50% 100%);
    background: linear-gradient($light 0%, $dark 60%);
  }
  .sail:nth-child(2) {
    width: 80px;
    height: 70px;
    left: 15px;
    bottom: 10px;
    transform: scaleX(-1);
    clip-path: polygon(50% 0%, 0% 100%, 50% 100%);
    background: linear-gradient($light 0%, $dark 60%);
  }
}

the transform: scaleX(-1) on the second sail is used to flip it horizontally.

And with :before and :after selectors, we can add a shadow and a trail. The z-index:-1 property ensures these elements are placed behind their siblings:

.boat {
  width: 90px;
  height: 90px;
  bottom: 90px;
  &:after {
    height: 8px;
    width: 200px;
    background: linear-gradient(90deg, transparentize($x-light, 0.3) 30%, rgba(255, 255, 255, 0) 100%); 
    border-radius: 40%;
    top: 105px;
    left: 20px;
    z-index: -1;
  }
  &:before {
    width: 92px;
    height: 50px;
    left: 25px;
    bottom: -70px;
    background: linear-gradient(to bottom, transparentize($x-dark, 0.2) 30%, transparentize($x-dark, 1) 100%);
    z-index: -1;
  }
  //...
}

Using CSS transforms

The background part of our illustration is done. Let's move on to the lighthouse.

We'll be using a .lighthouse-group div that will act as the main container for this part of the illustration. It has two main components, the land, and the actual lighthouse. Let's place .lighthouse-group on the document by assigning it a size and position:

.lighthouse-group {
    width: 50%;
    height: 100%;
    bottom: 0;
    left: 0;
    z-index: 2;
}

Let's add the bit of land first:

.land {
  width: 400px;
  height: 60px;
  left: -30px;
  bottom: 0;
  background-image: linear-gradient(to top, $x-dark 80%, $medium 100%);
  transform: skewX(35deg);
  border-radius: 10px;
}

With the transform property we can skew the element, then move it to the left and hide the left side.

Now for the actual lighthouse, we can start by giving a size and position to the lighthouse group:

.lighthouse-holder {
  height: 480px;
  width: 100px;
  bottom: 80px;
  left: 180px;
}

Then, to make the body of the lighthouse narrower at the top, we're going to use two transform properties, perspective and rotateX.

The perspective property sets a 3D space for the lighthouse element, then the rotateX property rotates it on the X axis on this 3D space. Technically, the top of the lighthouse is further away from us, while the bottom is nearer, but this gives us the trapeze effect we want.

.lighthouse {
  width: 100%;
  height: 100%;
  transform: perspective(600px) rotateX(20deg);
  background-color: $x-light;
}

Here's an excellent link if you wish to know more about CSS 3D transforms.

Next, to create the stripes, let's use a repeating gradient:

.lighthouse {
  width: 100%;
  height: 100%;
  transform: perspective(600px) rotateX(20deg);
  background-color: $x-light;
  background-image: repeating-linear-gradient(
    -40deg,
    transparent,
    transparent 60px,
    $dark 60px,
    $dark 120px
  );
}

I also want to add some shading with another gradient layered on top. We'll use the :after pseudo-selector for that:

.lighthouse {
  width: 100%;
  height: 100%;
  transform-style: preserve-3d;
  transform: perspective(600px) rotateX(20deg);
  background-color: $x-light;
  background-image: repeating-linear-gradient(
    -40deg,
    transparent,
    transparent 60px,
    $dark 60px,
    $dark 120px
  );
  &:after {
    width: 100%;
    height: 100%;
    opacity: 1;
    background-image: linear-gradient(
      90deg,
      transparentize($x-light, 0.4) 0%,
      $x-dark 8%,
      transparent 70%,
      transparentize($x-light, 0.6) 100%
    );
  }
}

And we also need an extra shadow at the bottom of the lighthouse. We can use the skewX property for this again:

.shadow {
  width: 117px;
  height: 50px;
  left: -32px;
  bottom: -70px;
  background: $x-dark;
  transform: skewX(-45deg);
}

To create the windows, we first need to add four .window elements to the HTML. To space them out, we can use a SASS loop like we did before. However this time, instead of using the random function, we can do a simple addition.

First, we're setting the original value of the bottom property to 90px. Then, at each run of the loop, we add 90px to this value. The end result is four identical .window elements that are each 90px apart vertically.

.windows
  - for (var i = 0; i < 4; i++)
    .window
.windows {
  height: 100%;
  width: 100%;
  .window {
    background-color: $x-dark;
    height: 25px;
    width: 15px;
    left: 43px;
    border-bottom: 2px solid rgba($light, 0.7);
    border-radius: 25px 25px 0 0;
  }
  $bottom: 90px;
  @for $i from 1 through (4) {
    .window:nth-of-type(#{$i}) {
      bottom: $bottom;
    }
    $bottom: $bottom + 90px;
  }
}

The door is pretty straightforward. For the stairs, let's use a combination of perspective and rotateX again to get a trapeze shape. Then a repeating gradient will create steps.

.door {
  background-color: $x-dark;
  height: 40px;
  width: 25px;
  left: 38px;
  bottom: -2px;
  border-radius: 2px 2px 0 0;
  .stairs {
    width: 27px;
    height: 28px;
    background-color: $x-dark;
    top: 34px;
    left: -1px;
    transform: perspective(100px) rotateX(45deg);
    background-image: repeating-linear-gradient(
      to bottom,
      $x-dark,
      $x-dark 4px,
      $light 4px,
      rgba(white, 0.1) 5px
    );
  }
}

Okay, the bottom part of the lighthouse is done. Let's move on to the top part.

First we need to add a few HTML elements:

.top
  .light-container
    .light
  .rail
  .middle
  .roof
    .roof-light
  .glow

We are first setting a size and a position for the .top container, and then, we can get started on the .base part of the structure. We're using the perspective + rotateX technique again to give it some shape, as well as a repeating gradient to create the rail.

.top {
  width: 94px;
  height: 60px;
  left: 3px;
  top: -15px;
    .rail {
      width: 100%;
      height: 17px;
      bottom: 1px;
      border: 3px solid $x-dark;
      border-radius: 1px;
      transform: perspective(1000px) rotateX(-35deg);
      background-image: repeating-linear-gradient(
        90deg,
        $x-dark,
        $x-dark 3px,
        $grey 3px,
        $yellow 10px
      );
      background-position: -2px 0;
   }
}

The .middle part is very similar, except we don't need a transform. We're also using the :before selector to add a nice glow behind it:

.middle {
  width: 88px;
  height: 35px;
  left: 3px;
  bottom: 14px;
  border: 2px solid $x-dark;
  border-radius: 3px;
  background-image: repeating-linear-gradient(
    90deg,
    $x-dark,
    $x-dark 4px,
    $grey 4px,
    rgba(255, 255, 255, 0) 21px
  );
  background-position: -2px 0;
  &:before {
    width: 100%;
    height: 100%;
    z-index: -1;
    background-color: $yellow;
    box-shadow: 0 0 10px $x-light, 0 0 20px $yellow, 0 0 30px $yellow,
    0 0 40px $yellow, 0 0 70px $yellow;
  }
}

For the roof, we can use the border technique to create a triangular shape.

There are a handful of ways to create triangles in css. Earlier we used the clip-path method to create the triangular sails of the boat. In this case however, I want to use :before and :after to create the top parts of the roof, which clip-path wouldn't let me do.

.roof {
  width: 0px;
  height: 0px;
  left: -3px;
  bottom: 45px;
  border-left: 50px solid rgba(255, 255, 255, 0);
  border-right: 50px solid rgba(255, 255, 255, 0);
  border-bottom: 40px solid $x-dark;
  &:before {
    width: 14px;
    height: 14px;
    left: -7px;
    bottom: -7px;
    background-color: $x-dark;
    border-radius: 50%;
  }
  &:after {
    width: 4px;
    height: 14px;
    left: -2px;
    bottom: 5px;
    background-color: $x-dark;
    border-radius: 3px;
  }
}

Last we need to add some light on the roof. We've run out of pseudo-selectors so we need to create a new HTML .roof-light element. This time I'm using the clip-path property to make sure the gradient is contained in the roof:

.roof-light {
  width: 100px;
  height: 40px;
  left: -50px;
  clip-path: polygon(50% 0, 0% 100%, 100% 100%);
  background-image: linear-gradient(
    135deg,
    $x-dark 40%,
    rgba($yellow, 0.5) 100%
  );
}

The last element of our illustration is the light. Because we are going to animate it in a moment on a 3D space, we need two elements. The .light-container element will be used to rotate the light from back to front. The .light element is used to give the light its trapeze shape:

.light-container {
  height: 40px;
  width: 35vw;
  bottom: 4px;
  left: 40px;
  transform-style: preserve-3d;
  transform-origin: left bottom;
  transform: perspective(500px) rotateY(0deg);
  .light {
    width: 100%;
    height: 100%;
    transform-style: preserve-3d;
    transform-origin: left center;
    transform: perspective(500px) rotateY(-35deg);
    background: linear-gradient(90deg, $yellow 40%, rgba(255, 255, 255, 0) 100%);
  }
}

And the last touch is to add a glow when the light rotates towards us. We'll set its opacity to 0 for now:

.glow {
  width: 100px;
  height: 60px;
  top: 0;
  left: 0;
  background-color: $yellow;
  opacity: 0;
  border-radius: 50%;
  box-shadow: 0 0 10px $yellow, 0 0 20px $yellow, 0 0 30px $yellow, 0 0 40px $yellow, 0 0 50px $yellow, 0 0 60px $yellow, 0 0 70px $yellow, 0 0 80px $yellow;
}

And our illustration is complete! This was a lot of work, but we're not quite done yet. It's time for animations!

Here are the elements we are going to animate:

  • the stars will twinkle
  • the waves will move from left to right
  • the boat will move across the screen
  • the shadow of the boat will transform as it moves away from the moon
  • the light will rotate from back to front
  • a glow will appear when the light faces us

The star animation is simple as we are only animating the opacity property. We've previously added a random animation delay to the star elements, which ensures the animation doesn't run simultaneously for all stars.

@keyframes twinkle {
  0%,
  100% {
    opacity: 1;
  }
  50% {
    opacity: 0.3;
  }
}

.star {
  //...
  animation: twinkle 5s linear infinite;
}

The wave animation is an easy one too:

@keyframes wave {
  0%,
  100% {
    transform: translateX(0);
  }
  50% {
    transform: translateX(-10px);
  }
}

.wave {
  //...
  animation: wave 5s linear infinite;
}

Let's animate the boat. Initially, it needs to be off screen. We can use the translateX property to move the boat to the far right of the screen. During the animation, the boat will appear to the right, then slowly move to the left, then disappear off screen again. I've also added the scale(0.8) value to reduce its size a bit.

@keyframes boat {
  0% {
    transform: translateX(120vw) scale(0.8);
  }
  80%,
  100% {
    transform: translateX(-25vw) scale(0.8);
  }
}

We also need to animate its shadow as it passes in front of the moon, then moves away from it:

@keyframes boatShadow {
  0% {
    transform: skewX(35deg) translateX(15px);
  }
  50%,
  100% {
    transform: skewX(-55deg) translateX(-40px);
  }
}
$boatSpeed: 100s;

.boat { 
  //... 
  animation: boat $boatSpeed linear infinite; 
  &:after { 
    //... 
    animation: boatShadow $boatSpeed linear infinite; 
  }
}

The light animation is the most complex one. It needs to rotate to the back first, then rotate to the front, then go back to its initial state at the end of the animation.

The initial state is the one we have defined in our .light-container selector already. To rotate it to the back, we are rotating it on its Y axis by 35 deg. Then to rotate to the front, we do the same but with a negative value:

@keyframes lightRotate {
  0%,
  100% {
    // initial and final state
    transform: perspective(500px) rotateY(0deg);
  }
  25% {
    //rotates to the back
    transform: perspective(500px) rotateY(35deg);
  }
  75% {
    // rotates to the front
    transform: perspective(500px) rotateY(-35deg);
  }
}

$lightSpeed: 40s;

.light-container {
  //...
  animation: lightRotate $lightSpeed linear infinite;
}

And lastly, we also need to make the glow appear when the light is rotating towards us:

@keyframes lightGlow {
  0%, 50%, 100% {
    opacity: 0;
  }
  75% {
    opacity: 1;
  }
}

.glow {
  //...
  animation: lightGlow $lightSpeed linear infinite;
}

Aaaaand we're done!

3D transforms can be a bit tricky to understand, and if you're struggling a bit, here's a tip: add a border to all elements that have a 3D transform. It'll reveal the shape and position of all elements, including the hidden ones.

Eg:

.light-container {
  //...
  border: 1px solid white;
  .light {
    border: 1px solid pink;
  }
}

Here's the final project in Codepen.

Next steps

So you've followed along the tutorial and built and animated your first CSS images. Now what? If you don't know where to begin, I suggest starting by replicating existing illustrations. Look on Dribbble and use a simple illustration you like as a reference (as long as you give credit to the illustrator and link to the original illustration, it's totally fine!). Starting with a model will take away the creative aspect and let you focus on the code and logic. When you are comfortable enough with the process, you'll find it easier to create your own CSS images from scratch.

As a rule of thumb, it is best to go for a ‘flat design’ look, with geometric shapes, no textures and flat colors, as they're easier to replicate with CSS. But then again, with a little imagination and experimentation, you can achieve amazing results. Here are a few examples of impressive pure CSS illustrations:

Useful tools and resources

That's it folks! I hope you enjoyed this series and learned a few things along the way. Follow me on CodePen to see what I'm up to, or hit me up on Twitter to keep in touch.

Posted on by:

agathacco profile

Agathe Cocco

@agathacco

I'm a self-taught front-end developer with a passion for design and animations. I'm from France but currently live in London.

Discussion

markdown guide
 

Thanks Agathe, I really enjoyed this series.

CSS animation is one of my weakness so this helped me a lot in gaining more understanding about it. I still have a lot to learn but that's the exciting part.:D

Hope to see more of your posts.

Cheers!

 

This post is just awesome! Although I had some issues with z-index and some elements just didn't want to show up cause of "bottom: 0;" in CodePen(Any guess why?). At least I made it in my own way of sort and I learned a hell lot out of these 3 posts.

Thank you and keep up the good work!

 

Not sure... if you add the link to your pen, I'd be happy to have a look

 

oh dear! that's because there's no size or position for the 'lighthouse-group' element. This is 100% my fault, I forgot this step in the tutorial (I've now amended the post). If you add the below CSS, and use the bottom property again, your issues should be fixed

.lighthouse-group {
width: 50%;
height: 100%;
bottom: 0;
left: 0;
z-index: 2;
}

 
 
 

Can someone explain why we assign $size: random(4)+px; before starting the
.star:nth-of-type(#{$i}) {

 

Agathe, these were a blast to run through this morning! They've sparked my creative motor!

 

Yay! I hope you had fun :)

 

thank you for the amazing tutorial. you can have a look at my work following your steps
0602379da2154291bc5e785b06d6562a.p...

 

Wow! I'm just speechless. What an awesome guide. Thanks a ton!

 

Thank you for that really good explain itself series. This really help me a lot. Best wishes.