← Hover & cursor

Googly Eyes

Cartoon eyes follow the pointer wherever it goes on the page. The pupils are loose, like real googly eyes: they swing after the pointer, bump against the rim and jiggle before they settle, and now and then the eyes blink.

How to use it

  1. Paste the snippet just before </head>.
  2. Add an empty <span data-googly-eyes> wherever you want a pair of eyes. The script builds the eyes inside it.

Options

Add any of these attributes to the same element.

data-countHow many eyes. Default 2.
data-sizeWidth of each eye, in px. Default 56.
data-pupilPupil size, as a share of the eye. Default 0.46.
data-colorPupil colour. Default #111.
data-no-blinkThe eyes never blink.
data-autoplayThe eyes look about on their own while nobody moves a pointer. Handy for demos and touch screens.

The eyes watch the pointer anywhere on the page, not just over them. On touch screens they look at where you last touched. With reduced motion turned on, the pupils point straight at the pointer without swinging or blinking.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Googly Eyes: cartoon eyes that follow the pointer around the page. The
    // pupils are loose, like real googly eyes: they swing after the pointer,
    // bump against the rim and jiggle before settling. Now and then they blink.
    // Usage: <span data-googly-eyes></span>  (builds a pair of eyes inside)
    // Optional: data-count (how many eyes, default 2), data-size (eye width, px,
    // default 56), data-pupil (pupil size as a share of the eye, default 0.46),
    // data-color (pupil colour, default #111), data-no-blink (never blink),
    // data-autoplay (the eyes look about on their own while nobody moves a
    // pointer; handy for demos and touch screens).
    var DEFAULTS = { count: 2, size: 56, pupil: 0.46, color: '#111' };

    var css =
      '.gge{display:inline-flex;align-items:center;justify-content:center;gap:.12em;vertical-align:middle}' +
      '.gge-eye{position:relative;display:block;flex:none;border-radius:50%;background:radial-gradient(circle at 38% 32%,#fff 55%,#e9e6ef);' +
      'box-shadow:inset 0 -3px 6px rgba(0,0,0,.12),0 0 0 2px rgba(0,0,0,.78),0 4px 10px rgba(0,0,0,.18);transform-origin:50% 50%}' +
      '.gge-pupil{position:absolute;left:50%;top:50%;border-radius:50%;will-change:transform}' +
      '.gge-pupil::after{content:"";position:absolute;left:22%;top:18%;width:28%;height:28%;border-radius:50%;background:#fff;opacity:.9}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
    function num(v, def) { return v !== undefined && v !== '' && !isNaN(+v) ? +v : def; }

    // One shared pointer for every set of eyes on the page.
    var ptr = { x: -1, y: -1, last: -1e9 };
    function track(e) {
      ptr.x = e.clientX; ptr.y = e.clientY; ptr.last = Date.now();
    }
    window.addEventListener('pointermove', track, { passive: true });
    window.addEventListener('pointerdown', track, { passive: true });

    function setup(el) {
      var d = el.dataset;
      var count = Math.max(1, Math.min(12, Math.round(num(d.count, DEFAULTS.count))));
      var size = num(d.size, DEFAULTS.size);
      var pupilShare = Math.max(0.2, Math.min(0.8, num(d.pupil, DEFAULTS.pupil)));
      var auto = d.autoplay !== undefined && d.autoplay !== 'false';
      var blinks = d.noBlink === undefined;
      var still = reduced();
      el.classList.add('gge');
      el.setAttribute('aria-hidden', 'true');

      var eyes = [];
      for (var i = 0; i < count; i++) {
        var eye = document.createElement('span');
        eye.className = 'gge-eye';
        eye.style.width = eye.style.height = size + 'px';
        var pupil = document.createElement('span');
        pupil.className = 'gge-pupil';
        var ps = size * pupilShare;
        pupil.style.width = pupil.style.height = ps + 'px';
        pupil.style.margin = (-ps / 2) + 'px 0 0 ' + (-ps / 2) + 'px';
        pupil.style.background = d.color || DEFAULTS.color;
        eye.appendChild(pupil);
        el.appendChild(eye);
        eyes.push({ eye: eye, pupil: pupil, x: 0, y: (Math.random() - 0.5) * 4, vx: 0, vy: 0 });
      }

      var visible = false, raf = 0, t = Math.random() * 10, dart = 0, dartAt = 0;
      var fake = { x: 0, y: 0 };

      function frame() {
        raf = 0;
        if (!visible) return;
        var now = Date.now();
        var idle = now - ptr.last > 2500;
        var px = ptr.x, py = ptr.y;
        var box = el.getBoundingClientRect();
        var cx = box.left + box.width / 2, cy = box.top + box.height / 2;
        if (auto && idle) {
          // A pretend pointer: a slow wander, with a quick dart now and then.
          t += 0.016;
          if (now > dartAt) { dart = (dart + 1) % 4; dartAt = now + 900 + Math.random() * 1600; }
          var r = Math.max(120, box.width * 1.2);
          var a = t * 1.1 + dart * 1.7;
          fake.x = cx + Math.cos(a) * r;
          fake.y = cy + Math.sin(a * 1.3) * r * 0.6;
          px = fake.x; py = fake.y;
        } else if (ptr.last < 0) {
          px = cx; py = cy + 40;
        }
        var moving = false;
        for (var i = 0; i < eyes.length; i++) {
          var e = eyes[i];
          var b = e.eye.getBoundingClientRect();
          var ex = b.left + b.width / 2, ey = b.top + b.height / 2;
          var maxR = size * (1 - pupilShare) / 2 - size * 0.04;
          var dx = px - ex, dy = py - ey, dist = Math.sqrt(dx * dx + dy * dy) || 1;
          var reach = Math.min(1, dist / (size * 2.5)) * maxR;
          var tx = dx / dist * reach, ty = dy / dist * reach;
          if (still) {
            e.x = tx; e.y = ty;
          } else {
            // Loose spring with little damping, so the pupil overshoots.
            e.vx = (e.vx + (tx - e.x) * 0.07) * 0.86;
            e.vy = (e.vy + (ty - e.y) * 0.07 + 0.25) * 0.86; // a touch of gravity
            e.x += e.vx; e.y += e.vy;
            var m = Math.sqrt(e.x * e.x + e.y * e.y);
            if (m > maxR) {
              // Bounce off the rim.
              var nx = e.x / m, ny = e.y / m;
              e.x = nx * maxR; e.y = ny * maxR;
              var vn = e.vx * nx + e.vy * ny;
              if (vn > 0) { e.vx -= 1.6 * vn * nx; e.vy -= 1.6 * vn * ny; }
            }
            if (Math.abs(e.vx) + Math.abs(e.vy) > 0.02) moving = true;
          }
          e.pupil.style.transform = 'translate(' + e.x.toFixed(2) + 'px,' + e.y.toFixed(2) + 'px)';
        }
        if (!still || moving) raf = requestAnimationFrame(frame);
      }
      function wake() { if (!raf && visible) raf = requestAnimationFrame(frame); }
      if (still) {
        window.addEventListener('pointermove', wake, { passive: true });
        window.addEventListener('scroll', wake, { passive: true });
      }

      function blink() {
        if (visible && !still && typeof anime !== 'undefined') {
          anime.animate(eyes.map(function (e) { return e.eye; }), {
            scaleY: [1, 0.08, 1], duration: 220, ease: 'inOut(2)',
          });
        }
        setTimeout(blink, 2600 + Math.random() * 3800);
      }
      if (blinks && !still) setTimeout(blink, 1200 + Math.random() * 2400);

      new IntersectionObserver(function (entries) {
        visible = entries[0].isIntersecting;
        if (visible) wake();
      }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-googly-eyes]').forEach(function (el) {
        if (el._gge) return;
        el._gge = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<span data-googly-eyes></span>

<!-- one big eye with a blue pupil: -->
<span data-googly-eyes data-count="1" data-size="90" data-color="#1d4ed8"></span>