See the Pen Hippo Button by Mariusz Dabrowski (@MarioD) on CodePen.

In this post, I will take you through how to create this interactive hippo button from the idea process all the way to the finished product you see above.

I also recorded a video version (Slightly different than the written version, but the same result and concept)

If you want to jump ahead and look at the code, I created a GitHub repo with commits along each step.


Table of Contents


Idea and design process


Coming up with the idea

I recently set up my Unsplash account and was browsing some of their nature photos when I came across the two below:

Hippo Alligator


These photos gave me the idea of an interactive mouth button that opens and closes on hover. This inspired some very rough sketches, which helped solidfy my idea:

I then moved over to Illustrator to digitize the design.


Designing in Illustrator

I designed both an open and closed mouth state and followed a few basic rules to keep everything simple:

  • Basic shapes only
  • Solid colours only (no gradients)
  • Room for text when the mouth is open or closed


I named and grouped my layers logically to make it easier to select elements in my code later on:

I combined the open and closed mouth states into one vector so that it is capable of looking like either state. Don't worry, we will create a SVG clipPath to hide the bottom part of the mouth later.

You can download this SVG here.


Preparing the SVG for code

The exported SVG from Illustrator is quite messy and contains unncessary code. We can clean this up:

  • Remove unecessary code and comments that Illustrator adds:
    • Everything above the SVG element can be removed
    • On our SVG element we only need to keep the viewBox and xmnls tags
  • Remove extra white space and indentation
  • Rename the selector names
    e.g., ear-left-outer_1_ to ear-left-outer
  • Move the IDs into classes
    e.g., <element id="ear-left-outer"> to <element class="ear-left-outer">
  • Update the default class selectors to point to our new class names
    e.g., .st0{background: red;} to .ear-left-outer, .ear-right-outer{background: red;}
  • Remove unecessary groups


Exported SVG straight from Illustrator

  <?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
<svg version="1.1" id="mouth-closed_1_" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px"
     y="0px" viewBox="0 0 242 109" style="enable-background:new 0 0 242 168.2;" xml:space="preserve">
<style type="text/css">
    .st0{fill:#919191;}
    .st1{fill:#6D6D6D;}
    .st2{fill:#AAAAAA;}
    .st3{fill:#FFFFFF;}
    .st4{fill:#8C8C8C;}
    .st5{fill:#7C7C7C;}
    .st6{fill:#FF4848;}
    .st7{fill:#FFFFE1;}
</style>
<g id="ears_1_">
    <g id="ear-left_1_">

            <ellipse id="ear-left-outer_1_" transform="matrix(0.9391 -0.3436 0.3436 0.9391 -3.6062 17.8444)" class="st0" cx="48.5" cy="19.1" rx="11.4" ry="13.8"/>

            <ellipse id="ear-left-inner_1_" transform="matrix(0.9391 -0.3436 0.3436 0.9391 -3.8876 17.4659)" class="st1" cx="47.3" cy="19.7" rx="7.3" ry="11.2"/>
    </g>
  ...

Cleaned-up SVG, following the checklist above (download here)

  <svg viewBox="0 0 242 109" xmlns="http://www.w3.org/2000/svg">
  <style type="text/css">
    .ear-left-outer, .ear-right-outer {fill:#919191;}
    .ear-left-inner, .ear-right-inner {fill:#6D6D6D;}
    .eye-right-outer, .eye-left-outer, .nostril-right-outer, .nostril-left-outer, .body {fill:#AAAAAA;}
    .eye-right-inner, .eye-left-inner {fill:#FFFFFF;}
    .nostril-right-inner, .nostril-left-inner{fill:#8C8C8C;}
    .freckle {fill:#7C7C7C;}
    .tongue {fill:#FF4848;}
    .tooth-left, .tooth-right {fill:#FFFFE1;}
  </style>
  <g class="ears">
    <g class="ear-left">
      <ellipse class="ear-left-outer" transform="matrix(0.9391 -0.3436 0.3436 0.9391 -3.6062 17.8444)" cx="48.5" cy="19.1" rx="11.4" ry="13.8"/>
      <ellipse class="ear-left-inner" transform="matrix(0.9391 -0.3436 0.3436 0.9391 -3.8876 17.4659)" cx="47.3" cy="19.7" rx="7.3" ry="11.2"/>
    </g>
    <g class="ear-right">
      <ellipse class="ear-right-outer" transform="matrix(0.3436 -0.9391 0.9391 0.3436 106.5379 189.869)" cx="189.1" cy="18.7" rx="14.4" ry="11.9"/>
      <ellipse class="ear-right-inner" transform="matrix(0.3436 -0.9391 0.9391 0.3436 106.8522 191.5127)" cx="190.4" cy="19.3" rx="11.7" ry="7.7"/>
    </g>
  </g>
...


Setting up the project


Folder structure
  • css/styles.css
  • js/app.js
  • index.html


index.html
  • Start with a basic HTML5 template
  • Add a reference to our CSS and JS file
  • Add a button, where our SVG will live
  • Add a reference to the GreenSock Animation Platform CDN (we will use this library for our animation)
  <!DOCTYPE html>
<html>
<head>
  <title>Hippo Button</title>
  <link rel="stylesheet" href="css/styles.css">
</head>
<body>
  <button></button>

  <script src="https://cdn.jsdelivr.net/npm/gsap@3.0.1/dist/gsap.min.js"></script>
  <script src="js/app.js"></script>
</body>
</html>


styles.css
  • Give the <body> a background colour
  • Reset the margin on the <body>
  • Centre the button in the middle of the screen using flexbox
  html,
body {
  height: 100%;
}

body {
  margin: 0;
  background: #1e313f;
  display: flex;
  align-items: center;
  justify-content: center;
}

  • Reset the default browser button styles
  • Give the button a width (this will be the hippo's width)
  button {
  background: transparent;
  border: 0;
  cursor: pointer;
  padding: 0;
  width: 242px;
}

app.js

Leave this file blank for now. We will populate this later.


Your project structure and files should now look like this.


Inserting the SVG into the project
  • Copy the SVG code into the <button></button> tag located in index.html
  • Move the contents of <styles></styles> into styles.css
index.html
  <!DOCTYPE html>
<html>
<head>
  <title>Hippo Button</title>
  <link rel="stylesheet" href="css/styles.css">
</head>
<body>
  <button>
    <svg viewBox="0 0 242 109" xmlns="http://www.w3.org/2000/svg">
      <g class="ears">
        <g class="ear-left">
          <ellipse class="ear-left-outer" transform="matrix(0.9391 -0.3436 0.3436 0.9391 -3.6062 17.8444)" cx="48.5" cy="19.1" rx="11.4" ry="13.8"/>
          <ellipse class="ear-left-inner" transform="matrix(0.9391 -0.3436 0.3436 0.9391 -3.8876 17.4659)" cx="47.3" cy="19.7" rx="7.3" ry="11.2"/>
        </g>
        <g class="ear-right">
          <ellipse class="ear-right-outer" transform="matrix(0.3436 -0.9391 0.9391 0.3436 106.5379 189.869)" cx="189.1" cy="18.7" rx="14.4" ry="11.9"/>
          <ellipse class="ear-right-inner" transform="matrix(0.3436 -0.9391 0.9391 0.3436 106.8522 191.5127)" cx="190.4" cy="19.3" rx="11.7" ry="7.7"/>
        </g>
      </g>
      <g class="eyes">
        <g class="eye-right">
          <path class="eye-right-outer" d="M174.9,27H186c0-0.3,0-0.7,0-1c0-14.4-11.6-26-26-26c-14.4,0-26,11.6-26,26 c0,0.3,0,0.7,0,1h6.1H174.9z"/>
          <path class="eye-right-inner" d="M175,25c0-11-7.8-20-17.5-20S140,14,140,25c0,0.7,0,1.3,0.1,2h34.8 C175,26.3,175,25.7,175,25z"/>
          <circle class="eye-right-pupil" cx="158" cy="18" r="5"/>
        </g>
        <g class="eye-left">
          <path class="eye-left-outer" d="M96.9,27h6.1c0-0.3,0-0.7,0-1c0-14.4-11.6-26-26-26C62.6,0,51,11.6,51,26 c0,0.3,0,0.7,0,1h11.1H96.9z"/>
          <path class="eye-left-inner" d="M97,25c0-11-7.8-20-17.5-20S62,14,62,25c0,0.7,0,1.3,0.1,2h34.8C97,26.3,97,25.7,97,25z" />
          <circle class="eye-left-pupil" cx="80" cy="17.7" r="5"/>
        </g>
      </g>
      <g class="nostrils">
        <g class="nostril-right">
          <ellipse class="nostril-right-outer" cx="130.5" cy="27.5" rx="6.5" ry="5.5"/>
          <circle class="nostril-right-inner" cx="130" cy="28" r="4"/>
        </g>
        <g class="nostril-left">
          <ellipse class="nostril-left-outer" cx="106.5" cy="27.5" rx="6.5" ry="5.5"/>
          <circle class="nostril-left-inner" cx="107" cy="28" r="4"/>
        </g>
      </g>
      <path class="body" d="M218,98H24C10.8,98,0,87.2,0,74V51c0-13.2,10.8-24,24-24h194c13.2,0,24,10.8,24,24v23 C242,87.2,231.2,98,218,98z"/>
      <g class="freckles">
        <circle class="freckle" cx="13.7" cy="41.4" r="1.6"/>
        <circle class="freckle" cx="20.1" cy="44.7" r="1.6"/>
        <circle class="freckle" cx="19.6" cy="37.8" r="1.6"/>
      </g>
      <g class="mouth">
        <g class="mouth-pieces">
          <path class="mouth-back" d="M23.6,168.2l-3-56.1c0-7.8,6.4-14.1,14.1-14.1h172.4c7.8,0,14.1,6.4,14.1,14.1l-3,56.1"/>
          <path class="tongue" d="M174.9,168.2c-7.3-5-24.5-9.9-54.8-9.9s-48,5.1-54.8,9.9"/>
        </g>
      </g>
      <g class="teeth">
        <path class="tooth-left" d="M115,97.9v7.5c0,2-1.7,3.6-3.6,3.6H89.7c-2,0-3.6-1.7-3.6-3.6v-7.5H115z"/>
        <path class="tooth-right" d="M154,97.9v7.5c0,2-1.7,3.6-3.6,3.6h-21.7c-2,0-3.6-1.7-3.6-3.6v-7.5H154z"/>
      </g>
    </svg>       
  </button>

  <script src="https://cdn.jsdelivr.net/npm/gsap@3.0.1/dist/gsap.min.js"></script>
  <script src="js/app.js"></script>
</body>
</html>

styles.css
  html,
body {
  height: 100%;
}

body {
  margin: 0;
  background: #1e313f;
  display: flex;
  align-items: center;
  justify-content: center;
}

button {
  background: transparent;
  width: 240px;
  border: 0;
  cursor: pointer;
  padding: 0;
}

.ear-left-outer, .ear-right-outer {fill:#919191;}
.ear-left-inner, .ear-right-inner {fill:#6D6D6D;}
.eye-right-outer, .eye-left-outer, .nostril-right-outer, .nostril-left-outer, .body {fill:#AAAAAA;}
.eye-right-inner, .eye-left-inner {fill:#FFFFFF;}
.nostril-right-inner, .nostril-left-inner{fill:#8C8C8C;}
.freckle {fill:#7C7C7C;}
.tongue {fill:#FF4848;}
.tooth-left, .tooth-right {fill:#FFFFE1;}

Opening index.html in your browser should now show this:


Hiding the bottom part of the mouth with a SVG clipPath

To define a clipPath in SVG you will need to place your clipPath inside of a definitions element. A clipPath is defined by wrapping a shape inside of a clipPath element. The clipPath element requires an ID that will be used to reference this clipPath later on.

  <defs>
  <clipPath id="clipName">
    <circle cx="0" cy="0" r="1" />
  </clipPath>
</defs>

To use a clipPath, add the clip-path attribute with a reference to the clipPath ID on any element within your SVG that you want to use the clipPath on.

  <ellipse clip-path="url(#clipName)" cx="0" cy="0" rx="1" ry="1"/>


We will use the hippo's body shape to clip the bottom part of the mouth:

  • Create a new clipPath using a copy of the hippo's body shape (i.e., the path that contains the .body class)
  • Remove the class attribute from our shape
  • Apply the clipPath to the mouth group <g class="mouth">
  ...
<g class="freckles">
  <circle class="freckle" cx="13.7" cy="41.4" r="1.6"/>
  <circle class="freckle" cx="20.1" cy="44.7" r="1.6"/>
  <circle class="freckle" cx="19.6" cy="37.8" r="1.6"/>
</g>
<defs>
  <clipPath id="mouthClip">
    <path fill="#ffffff" d="M218,98H24C10.8,98,0,87.2,0,74V51c0-13.2,10.8-24,24-24h194c13.2,0,24,10.8,24,24v23 C242,87.2,231.2,98,218,98z"/>
  </clipPath>
</defs> 
<g class="mouth" clip-path="url(#mouthClip)">
  <g class="mouth-pieces">
    <path class="mouth-back" d="M23.6,168.2l-3-56.1c0-7.8,6.4-14.1,14.1-14.1h172.4c7.8,0,14.1,6.4,14.1,14.1l-3,56.1"/>
    <path class="tongue" d="M174.9,168.2c-7.3-5-24.5-9.9-54.8-9.9s-48,5.1-54.8,9.9"/>
  </g>
</g>
<g class="teeth">
  <path class="tooth-left" d="M115,97.9v7.5c0,2-1.7,3.6-3.6,3.6H89.7c-2,0-3.6-1.7-3.6-3.6v-7.5H115z"/>
  <path class="tooth-right" d="M154,97.9v7.5c0,2-1.7,3.6-3.6,3.6h-21.7c-2,0-3.6-1.7-3.6-3.6v-7.5H154z"/>
</g>
...

You should now see this in your browser:

We are ready to do some animating now!


Your project structure and files should now look like this.


Mouth Animation


GreenSock Animation Platform (GSAP)

We will use GSAP for the mouth animation. GSAP is a JavaScript animation platform that makes managing animations a breeze. If you have never used GSAP before you can check out their amazing documentation or instructional videos on their YouTube channel.


Why are we using GSAP instead of plain CSS?

It is possible to code this animation in plain CSS, but GSAP provides the following benefits:

  • A lot less code
  • More readable
  • Easier to modify
  • More flexibility for eases
  • Removes cross browser-related issues with animating SVGs

GSAP Timeline

Now the fun part. We will create our GSAP timeline and start animating our elements. Below is a basic example of how a GSAP timeline works. If you are more of a visual learner, the GSAP team has a great introduction video.

Create a new timeline and assign it to the mouthOpen variable.

  // --------------
// Hover animaton
// --------------

const mouthOpen = gsap.timeline({ repeat: -1 });

The timeline method can take an optional options object. In our case, we are passing the repeat option with a value of -1, which will cause our animation to loop infinitely. It is helpful to see the animation loop continuously while we tweak it.


Next, we will use the .to() method, which allows us to animate the properties of an element.

  // --------------
// Hover animaton
// --------------

const mouthOpen = gsap.timeline({ repeat: -1 });
mouthOpen.to('.mouth-back', {duration: 1, ease: Power2.easeOut, y: -70}, 0);

The following parameters are passed:

  • First parameter (.mouth-back): This is the element we want to target for our animation. You can use a string and GSAP will use document.querySelectorAll to target your element, or you can pass an element that you referenced yourself.
  • Second parameter ({}): This is the list of properties we want to animate, as well as any special properties such as ease and duration.
    • duration specifies how long this animation should take to complete.
    • ease controls the rate of change during the animation. A visual list of the different types of eases can be found here.
    • y: -70 moves the element -70 on the y-axis relative to our user coordinate system. If you do not know how the user coordinate system works, Sara Soueidan has a wonderful explanation.
  • Third parameter (0): This is the point in our timeline we want this animation to start. In our case, it is 0 seconds.

If you add the code above to app.js and refresh index.html, you should see this:

You may be wondering, how did we arrive at -70? How do we know this is how far the mouth needs to move? One way to get these values is directly from the browser:

  • Open your index.html file in the browser and inspect the element you want to manipulate. In our case it is the .mouth-back group.
  • Manipulate the element to its final state by using CSS transform.
  • Note these transforms and add them to your GSAP timeline.

For example, the above would be:

mouthOpen.to('.mouth-back', {duration: 1, ease: Power2.easeOut, y: -70}, 0);


Next, we continue adding .to() methods into our timeline to build out our animation:

  // --------------
// Hover animaton
// --------------

const mouthOpen = gsap.timeline({ repeat: -1 });
mouthOpen.to('.mouth-back', {duration: 1, ease: Power2.easeOut, y: -70}, 0);
mouthOpen.to('.tongue', {duration: 1.5, ease: Power2.easeOut, y: -70}, 0);
mouthOpen.to('.teeth', {duration: 1, ease: Power2.easeOut, y: -70, scaleY: 1.2}, 0);

Since we want the tongue, teeth, and mouth to move to the same location, we will move them all up by the same amount (-70).

  • I wanted the teeth to appear larger when the mouth opens. I achieved this by using the scaleY property to enlarge their height to 120%.
  • I also increased the time that the tongue will take to enter the view by 0.5s. This gives the tongue a slight parallax effect to make it feel more realistic.

Now at this point you may be seeing a trend with some of the values. We want the duration and ease to be the same across elements to keep them all in sync. We can move these values out into their own variables to make it easier to modify them all in one place:

  // --------------
// Hover animaton
// --------------

const mouthSpeed = 1;
const easeType = Power2.easeOut;
const mouthOpen = gsap.timeline({ repeat: -1 });
mouthOpen.to('.mouth-back', {duration: mouthSpeed, ease: easeType, y: -70}, 0);
mouthOpen.to('.tongue', {duration: mouthSpeed * 1.5, ease: easeType, y: -70}, 0);
mouthOpen.to('.teeth', {duration: mouthSpeed, ease: easeType, y: -70, scaleY: 1.2}, 0);

You may also notice that our last value is always 0. Again this is because we want all of our animations to start at the same time (0 seconds in the timeline).

  // --------------
// Hover animaton
// --------------

const mouthSpeed = 1;
const easeType = Power2.easeOut;
const mouthOpen = gsap.timeline({ repeat: -1 });
mouthOpen.to('.mouth-back', {duration: mouthSpeed, ease: easeType, y: -70}, 0);
mouthOpen.to('.tongue', {duration: mouthSpeed * 1.5, ease: easeType, y: -70}, 0);
mouthOpen.to('.teeth', {duration: mouthSpeed, ease: easeType, y: -70, scaleY: 1.2}, 0);
mouthOpen.to('.body', {duration: mouthSpeed, ease: easeType, scaleY: 1.06, transformOrigin: 'center bottom'}, 0);
mouthOpen.to('.freckles', {duration: mouthSpeed, ease: easeType, y: -10}, 0);
mouthOpen.to('.ears', {duration: mouthSpeed, ease: easeType, y: 6}, 0);

Continuing on to the .body. Here we use a new property transformOrigin: 'center bottom' to set the origin for where our element will transform from. You may be wondering why we do not just set this value in CSS. Here is a good explanation by Jack Doyle from GreenSock here.

  • We want the .body to stretch as if the mouth is opening so we use the scaleY: 1.06 to enlarge it vertically.
  • The freckles move up just a bit to maintain their position relative to the mouth.
  • Finally, the ears move down to seem as though they are tilting back.

  // --------------
// Hover animaton
// --------------

const mouthSpeed = 1;
const easeType = Power2.easeOut;
const mouthOpen = gsap.timeline({ repeat: -1 });
mouthOpen.to('.mouth-back', {duration: mouthSpeed, ease: easeType, y: -70}, 0);
mouthOpen.to('.tongue', {duration: mouthSpeed * 1.5, ease: easeType, y: -70}, 0);
mouthOpen.to('.teeth', {duration: mouthSpeed, ease: easeType, y: -70, scaleY: 1.2}, 0);
mouthOpen.to('.body', {duration: mouthSpeed, ease: easeType, scaleY: 1.06, transformOrigin: 'center bottom'}, 0);
mouthOpen.to('.freckles', {duration: mouthSpeed, ease: easeType, y: -10}, 0);
mouthOpen.to('.ears', {duration: mouthSpeed, ease: easeType, y: 6}, 0);
mouthOpen.to('.eye-right', {duration: mouthSpeed, ease: easeType, x: -2}, 0);
mouthOpen.to('.eye-left', {duration: mouthSpeed, ease: easeType, x: 2}, 0);
mouthOpen.to('.eyes', {duration: mouthSpeed, ease: easeType, y: 2}, 0);
mouthOpen.to('.nostrils', {duration: mouthSpeed, ease: easeType, y: -6}, 0);

  • .eye-right and .eye-left move closer to the center by 2, giving the eyes some perspective as the head tilts back.
  • We move the .eyes group down to give them the effect of slightly going behind the mouth as it opens.
  • Since the .nostrils are on top of the mouth, when the mouth opens we move them slightly upwards.

This brings our animation to this state:

Time to remove the infinite loop and trigger the animation on mouse hover.


Your project structure and files should now look like this.


Mouse Events

In app.js we will set up some functions to trigger when the cursor enters and leaves the bounds of our button.

  • Define a reference to our button
  • Add an event listener for mouseenter and mouseleave
  • Create an enterButton and leaveButton function and reference them to be triggered on our mouseeneter and mouseleave events
  // ------------
// Mouse events
// ------------

const button = document.querySelector('button');

button.addEventListener('mouseenter', enterButton);
button.addEventListener('mouseleave', leaveButton);

function enterButton() { console.log('hover over'); }
function leaveButton() { console.log('hover out'); }

To check that the code is working properly, open index.html in your browser and open the console. Hovering over the button should yield our console log messages.

To stop our animation from looping infinitely, we want to add a paused: true option to our timeline, as well as remove the repeat: -1 option.

  // --------------
// Hover animaton
// --------------

const mouthSpeed = 1;
const easeType = Power2.easeOut;
const mouthOpen = gsap.timeline({ paused: true });
mouthOpen.to('.mouth-back', {duration: mouthSpeed, ease: easeType, y: -70}, 0);
...

Now we can control our timeline by invoking the mouthOpen.play() method, and reverse our timeline by invoking the mouthOpen.reverse() method. Let us update our enter and leave functions:

  // ------------
// Mouse events
// ------------

const button = document.querySelector('button');

button.addEventListener('mouseenter', enterButton);
button.addEventListener('mouseleave', leaveButton);

function enterButton() { mouthOpen.play(); }
function leaveButton() { mouthOpen.reverse(); }

If you refresh index.html you will notice that the infinite looping is now gone, and your timeline triggers when the mouse hovers over the button and reverses when the mouse leaves the button. You may also notice that the animation is quite slow. We can increase the speed with const mouthSpeed = 0.3;:

  // --------------
// Hover animaton
// --------------

const mouthSpeed = 0.3;
const easeType = Power2.easeOut;
const mouthOpen = gsap.timeline({ paused: true });
...

This concludes the hover portion of the button. Let us look at some other ways to bring more life to our button.


Your project structure and files should now look like this.


Ear wiggle

To give the button some more life we will add a passive animation that will cause the ear to wiggle (rotate back and forth) every x seconds.

  // ----------
// Ear wiggle
// ----------

const earWiggle = gsap.timeline({ paused: true, repeat: 2 });
earWiggle.set('.ear-right', { transformOrigin:"center center" });

window.setInterval(() => earWiggle.play(0), 3500);

  • Create a new earWiggle timeline with the options of paused (to prevent our animation from looping infinitely) and repeat (to repeat the wiggle two times after it completes).
  • Use the .set() method to set the transform origin to the centre of the right ear.
  • Use the setInterval method, which allows us to trigger a function every x amount of time. In our case, we will play our timeline every 3500 ms. The 0 in earWiggle.play(0) tells the API to start the animation at 0 seconds every time it is played.
  // ----------
// Ear wiggle
// ----------

const earWiggle = gsap.timeline({ paused: true, repeat: 2 });
earWiggle.set('.ear-right', { transformOrigin:"center center" });
earWiggle.to('.ear-right', {duration: 0.1, rotation: 45});
earWiggle.to('.ear-right', {duration: 0.1, rotation: 0});

window.setInterval(() => earWiggle.play(0), 2500);

The animation here is fairly straightforward. We simply rotate the ear 45°, then back to its original position of 0. This repeats twice for a total of three wiggles:


Your project structure and files should now look like this.


Eye tracking

The final step is to have the eyes track the user's cursor.

We start by creating a clipPath for each pupil using the eye-right-inner and eye-left-inner as our clipping shapes. This is so the pupils do not leave the bounds of the whites of the eyes.

To create the clipPath we use the same method explained above.

The only additional thing to keep in mind here is that, if you plan on animating a shape that is being clipped, wrap it inside of a group, and apply the clipPath to the group instead of the shape itself.

  <g clip-path="url(#eye-right-clip)">
  <circle class="eye-right-pupil" cx="158" cy="18" r="5"/>
</g>

If the clipPath is on the shape itself when you go to move it, the clipPath will move along with your shape, making it seem like the cipping isn't working.

  <g class="eyes">
  <g class="eye-right">
    <path class="eye-right-outer" d="M174.9,27H186c0-0.3,0-0.7,0-1c0-14.4-11.6-26-26-26c-14.4,0-26,11.6-26,26 c0,0.3,0,0.7,0,1h6.1H174.9z"/>
    <path class="eye-right-inner" d="M175,25c0-11-7.8-20-17.5-20S140,14,140,25c0,0.7,0,1.3,0.1,2h34.8 C175,26.3,175,25.7,175,25z"/>
    <defs>
      <clipPath id="eye-right-clip" fill="#ffffff">
        <path d="M175,25c0-11-7.8-20-17.5-20S140,14,140,25c0,0.7,0,1.3,0.1,2h34.8 C175,26.3,175,25.7,175,25z"/>
      </clipPath>
    </defs>
    <g clip-path="url(#eye-right-clip)">
      <circle class="eye-right-pupil" cx="158" cy="18" r="5"/>
    </g>
  </g>
  <g class="eye-left">
    <path class="eye-left-outer" d="M96.9,27h6.1c0-0.3,0-0.7,0-1c0-14.4-11.6-26-26-26C62.6,0,51,11.6,51,26 c0,0.3,0,0.7,0,1h11.1H96.9z"/>
    <path class="eye-left-inner" d="M97,25c0-11-7.8-20-17.5-20S62,14,62,25c0,0.7,0,1.3,0.1,2h34.8C97,26.3,97,25.7,97,25z" />
    <defs>
      <clipPath id="eye-left-clip">
        <path d="M97,25c0-11-7.8-20-17.5-20S62,14,62,25c0,0.7,0,1.3,0.1,2h34.8C97,26.3,97,25.7,97,25z" />
      </clipPath>
    </defs>
    <g clip-path="url(#eye-left-clip)">
      <circle class="eye-left-pupil" cx="80" cy="17.7" r="5"/>
    </g>
  </g>
</g>

Now to get the pupils moving in unison with the cursor.


Step 1: Figure out how far left/right and up/down the pupil can move

We will focus on the x-axis first:

  • Get the width of the pupil
  • Get the width of the white area
  • Subtract the width of the pupil from the width of the white area
  • Divide the result by 2

For example, if the width of the white area is 100px and the width of the pupil is 20px, we get (100 - 20)/2. This means the pupil can move 40px to the left or right before it appears to move outside of the bounds of the white area of the eye.

We repeat the process for the y-axis using the height of the white area and the height of the pupil.

Here is how the code looks:

  // ------------
// Eye tracking
// ------------

const eyeRightPupil = document.querySelector('.eye-right-pupil');
const eyeLeftPupil = document.querySelector('.eye-left-pupil');
const eyeLeftInner = document.querySelector('.eye-left-inner');
const innerEyeWidth = eyeLeftInner.getBoundingClientRect().width;
const innerEyeHeight = eyeLeftInner.getBoundingClientRect().height;
const pupilWidth = eyeLeftPupil.getBoundingClientRect().width;
const pupilHeight = eyeLeftPupil.getBoundingClientRect().height;
const xMovement = (innerEyeWidth - pupilWidth)/2;
const yMovement = (innerEyeHeight - pupilHeight)/2;


Step 2: Figure out where our cursor is on the screen

To make the next part easier, we want to know the percentage position of where our cursor is on our screen.

  • To do this for the x-axis, take the cursor's x position and divide it by the browser's viewport width (document.body.clientWidth)
  • To do this for the y-axis, take the cursor's y position and divide it by the browser's viewport height (document.body.clientHeight)

For example, if our document is 200px wide and our cursor is at 50px on the x-axis, we get 50/200. This gives us .25 or 25%. If the cursor is at the far left of the screen we will get 0 and if it is at the far right we will get 1.

Here is what the code looks like:

  // ------------
// Eye tracking
// ------------

window.addEventListener('mousemove', updateEyePosition);

function updateEyePosition(e) {
  const mousePercentX = e.clientX / document.body.clientWidth;
  const mousePercentY = e.clientY / document.body.clientHeight;
}


Step 3: Sync the cursor movement to the pupil movement

The cursor position goes from 0 to 1 and the pupil movement goes from -40 to 40. To sync these two movements, we first need to convert the mouse movement to a value of -1 to 1. To do this, we can multiply by 2 and subtract 1.

  const posX = mousePercentX * 2 - 1;
const posY = mousePercentY * 2 - 1;

Now that we have a value of -1 to 1 we can multiply it by our allowed movement to get the pupil position:

  const posX = (mousePercentX * 2 - 1) * xMovement;
const posY = (mousePercentY * 2 - 1) * yMovement;


Step 4: Combine the code and apply the transform to our pupil

To move the pupil we use the CSS transform property and update it every time the cursor moves:

  eyeLeftPupil.style.transform = `translate(${posX}px, ${posY}px)`;
eyeRightPupil.style.transform = `translate(${posX}px, ${posY}px)`;

And here is the final code:

  // ------------
// Eye tracking
// ------------

const eyeRightPupil = document.querySelector('.eye-right-pupil');
const eyeLeftPupil = document.querySelector('.eye-left-pupil');
const eyeLeftInner = document.querySelector('.eye-left-inner');
const innerEyeWidth = eyeLeftInner.getBoundingClientRect().width;
const innerEyeHeight = eyeLeftInner.getBoundingClientRect().height;
const pupilWidth = eyeLeftPupil.getBoundingClientRect().width;
const pupilHeight = eyeLeftPupil.getBoundingClientRect().height;
const xMovement = (innerEyeWidth - pupilWidth)/2;
const yMovement = (innerEyeHeight - pupilHeight)/2;

window.addEventListener('mousemove', updateEyePosition);

function updateEyePosition(e) {
  const mousePercentX = e.clientX / document.body.clientWidth;
  const mousePercentY = e.clientY / document.body.clientHeight;
  const posX = (mousePercentX * 2 - 1) * xMovement;
  const posY = (mousePercentY * 2 - 1) * yMovement;

  eyeLeftPupil.style.transform = `translate(${posX}px, ${posY}px)`;
  eyeRightPupil.style.transform = `translate(${posX}px, ${posY}px)`;
}

You should now have a cute hippo head that follows your cursor.


Your project structure and files should now look like this.


Summary

Hopefully a few of you made it this far; if so, thank you for taking the time to read my tutorial. If you have any feedback, good or bad, make sure to leave a comment or message me on Twitter. I would love to know how to make the next tutorial even better.

Mariusz