How often do you get to 404 pages? Usually, they are not styled and stay default. Recently I’ve found which interactive character attracts attention and livens up the error page.

Probably, there was just a cat picture, then they thought up eyes movement and developer implemented the idea.imageNow user visits the page and checks out the effect. It’s cool and pleasant small feature, it catches, then user discusses it with colleagues or friends and even repeats the feature. It could be this easy, if not:

  1. Center point is not being renewed when user resizes the window. Open the browser window with small width viewport and resize to full screen, the cat looks not at the cursor.
  2. Center point is placed on the left eye, not in binocular center of the circle.
  3. When user hovers cursor between the eyes, apples of the eyes don’t get together and don’t focus. Eyes are looking to infinity, that’s why the cat looks not at user, it looks through him.
  4. Eyes movements are immediate, they need to be smooth.
  5. Apples' movements happen because of margin-left / margin-top changing. It’s incorrect, find explanation below.
  6. Eyes don’t move if cursor is under footer.

What I suggest

For a start, let’s implement flawless eyes movement.

1. Prepare markup

<div class="cat">
    <div class="cat__eye _left"></div>
    <div class="cat__eye _right"></div>

2. Get links to eyes’ elements

const cat = document.querySelector('.cat');
const eyes = cat.querySelectorAll('.cat__eye');
const eye_left = eyes[0];
const eye_right = eyes[1];

3. Register mousemove event listener and get cursor coordinates:

let mouseX;
let mouseY;
window.addEventListener('mousemove', e => {
    mouseX = e.clientX;
    mouseY = e.clientY;

I add mousemove listener on window object, not document body, because I need to use all screen to get mouse coordinates.

4. Movement
Since I’m going to smoothen movements, I can’t manage them in mousemove handler.

Add update method that will be fetched by requestAnimationFrame which is synchronized with browser renewal. Usually renewals happen 60 times per second, therefore we see 60 pics per second every 16.6 ms.

If developer supposes user’s browser can’t support requestAnimationFrame, developer can use setTimeout fallback or ready-made polyfill

window.requestAnimationFrame = (function () {
    return window.requestAnimationFrame ||
        window.webkitRequestAnimationFrame ||
        window.mozRequestAnimationFrame ||
        function (callback) {
            window.setTimeout(callback, 1000 / 60);

In order to renew or stable fetching of update in time, I register started variable

let started = false;
let mouseX;
let mouseY;
window.addEventListener('mousemove', e => {
    mouseX = e.clientX;
    mouseY = e.clientY;
        started = true;
function update(){
    // Here comes eyes movement magic

This way I got constantly fetching update method and cursor coordinates. Then I need to get values of apples movements inside the eyes.

I try to move both eyes as single element

let dx = mouseX - eyesCenterX;
let dy = mouseY - eyesCenterY;
let angle = Math.atan2(dy, dx);
let distance = Math.sqrt(dx * dx + dy * dy);
	distance = distance > EYES_RADIUS ? EYES_RADIUS : distance;

let x = Math.cos(angle) * distance;
let y = Math.sin(angle) * distance; = 'translate(' + x + 'px,' + y + 'px)'; = 'translate(' + x + 'px,' + y + 'px)';

Pretty simple: find dx and dy, which are coordinate difference between eyes center and mouse, find angle from center to cursor, using Math.cos and Math.sin methods get movement value for horizontal and vertical. Use ternary operator and limit eyes movement area.

Y value is given first for Math.atan2 method, then x value. As a result user notices unnaturalness of eyes motions and no focusing.

Make each eyes move and watch without reference to each other.

// left eye
let left_dx = mouseX - eyesCenterX + 48;
let left_dy = mouseY - eyesCenterY;

let left_angle = Math.atan2(left_dy, left_dx);
let left_distance = Math.sqrt(left_dx * left_dx + left_dy * left_dy);
     left_distance = left_distance > EYES_RADIUS ? EYES_RADIUS : left_distance;

let left_x = Math.cos(left_angle) * left_distance;
let left_y = Math.sin(left_angle) * left_distance; = 'translate(' + left_x + 'px,' + left_y + 'px)';

// right eye
let right_dx = mouseX - eyesCenterX - 48;
let right_dy = mouseY - eyesCenterY;
let right_angle = Math.atan2(right_dy, right_dx);
let right_distance = Math.sqrt(right_dx * right_dx + right_dy * right_dy);
     right_distance = right_distance > EYES_RADIUS ? EYES_RADIUS : right_distance;

let right_x = Math.cos(right_angle) * right_distance;
let right_y = Math.sin(right_angle) * right_distance; = 'translate(' + right_x + 'px,' + right_y + 'px)';

Interesting but worse than previous result, eyes move up and down independently. So I used first demo as a movement mechanic basic and make apples of the eyes get together when cursor is about the center of character.

I’ll not describe entire code, please find hereby a result:

By trial and error I’ve matched needed parameters for eyes movement and focusing. So now I need smoothing.


Link TweenMax library and code something like this? eye, 0.15, {x: x, y: y});

Linking entire lib for simple task does not make sense, therefore, I make smoothing from scratch.

Put the case that there is only one eye element on the page and its displacement area is not limited at all. To smoothen mouse coordinates values, I use this mechanics:

const SMOOTHING = 10;
x += (needX - x) / SMOOTHING;
y += (needY - y) / SMOOTHING; = 'translate3d(' + x + 'px,' + y + 'px,0)';

I use translate3d to separate eyes to another rendering stream and speed them up.

The trick is that every 16.6ms (60 pics per second) variable x and y tend to needed values. Each renew closes value to its needed one for 1/10 of difference.

let x = 0;
let needX = 100;
let SMOOTHING = 2;
function update(){
    x += (needX - x) / SMOOTHING;

Then every 16.6 ms renew we get simple smoothing and next x values (approx):


A couple more unobvious tricks:

— Start this examination to optimize workload

if(x != needX || y != needY){ = 'translate3d(' + x + 'px,' + y + 'px,0)';

But you have to equate x to needX when they get as close as eyes positions are almost the same

if(Math.abs(x - needX) < 0.25){
    x = needX;
if(Math.abs(y - needY) < 0.25){
    y = needY;

Otherwise x and y values will be reaching needX and needY too long; there will be no visual differences, but every screen change will affect eyes styles. Btw you can fiddle around with it yourself.

let x = 0;
let needX = 100;
let smoothing = 2;

function update(){
    x += (needX - x) / smoothing;
    if( Math.abs(x - needX) > 0.25 ){ // replace 0.25 with anything else and check number of x renewals.
    } else {
        x = needX;

    console.log( x.toString(10) );

— If mechanics above is clear, you can create more complex effects, e.g. spring. The simplest smoothing and to cursor approximation looks like this:

x += (mouseX - x) / smoothing;
y += (mouseY - y) / smoothing;

Add smoothing a difference between needed and current coordinates values.

Sometimes approximation limitation makes sense. There is example above where value changes from 0 to 100, so in the 1st iteration value reaches “50”, it is pretty huge figure for 1 step. This mechanics kinda remind paradox of Achilles and the tortoise


Hide and show apples of eyes every 2-3 seconds. The most trivial method is «display: none;», «transform: scaleY(N)» with dynamic value of y-scale is a bit more complex.

Create 2 consts

const BLINK_COUNTER_LIMIT = 180; — number of renewals before start of blinking,
const BLINKED_COUNTER_LIMIT = 6; — number of renewals during one wink.

And 2 variables, which values will change every renewal.

let blinkCounter = 0;
let blinkedCounter = 0;

Code of winking

let blinkTransform = '';
if(blinkCounter > BLINK_COUNTER_LIMIT){
    if(blinkedCounter > BLINKED_COUNTER_LIMIT){
        blinkCounter = 0;
    } else {
        blinkTransform = ' scaleY(' + (blinkedCounter / BLINKED_COUNTER_LIMIT) + ')';
} else {
    blinkedCounter = 0;

BlinkTransform is stroke variable that has empty value between winking and following ones during winking

' scaleY(0.17)'
' scaleY(0.33)'
' scaleY(0.50)'
' scaleY(0.67)'
' scaleY(0.83)'
' scaleY(1.00)'

All calculations give variable blinkTransform, value of which should be added to css code of eyes position transform. Thus empty string gets added in case of 3s down time and it doesn’t effect on eyes scale, css value gets added during blinking. = 'translate(' + xLeft + 'px,' + y + 'px)' + blinkTransform; = 'translate(' + xRight + 'px,' + y + 'px)' + blinkTransform;

Lesson of the story

Every day we meet things that seem simple and obvious and we even don't understand that this external simplicity hides a colossal amount of questions and improvements. In my opinion devil is in the details that form entire final result. Muhammad Ali the best boxer of 20th century raised heel of rear foot in the moment of straight punch. This manoeuvre increased effective distance of blow and gave him more chances to win. It always worked.

P.S. I have no bearing on the website and hope its owners would not take offence at my comments. For convenience I named apple of the eye = eye in code.
Share post

Comments 0

Only users with full accounts can post comments. Log in, please.