Interactive Hippo Button Tutorial
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
- Setting up the project
- Mouth animation
- Ear wiggle
- Eye tracking
- Summary
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:
- Remove extra white space and indentation
- Rename the selector names
e.g.,ear-left-outer_1_
toear-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/[email protected]/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 inindex.html
- Move the contents of
<styles></styles>
intostyles.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/[email protected]/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 usedocument.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 asease
andduration
.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 thescaleY: 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
andmouseleave
- Create an
enterButton
andleaveButton
function and reference them to be triggered on ourmouseeneter
andmouseleave
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 ofpaused
(to prevent our animation from looping infinitely) andrepeat
(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 inearWiggle.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