← back to Crazy News Channel Shadowman

assets/eyes.js

62 lines

/* eyes.js — zero-dependency "googly eyes follow the mouse" drop-in.
 * Usage: <div data-eyes></div><script src="eyes.js"></script>
 * Options (data-attributes on the host):
 *   data-eye-size="200"      eye diameter px (default 200)
 *   data-pupil-ratio="0.38"  pupil diameter / eye diameter
 *   data-gap="8"             px between eyes
 *   data-eye-color="#FAF0DF" data-pupil-color="#000"
 *   data-corner="bottom-right"  pin the eyes fixed in a viewport corner
 *                               (top-left|top-right|bottom-left|bottom-right)
 *   data-offset-x="16px" data-offset-y="16px"  inset from that corner (any CSS length/calc)
 *   data-z="25"                 z-index when pinned
 * The pupil centre is clamped to (R_eye - R_pupil) so it never leaves the eye. */
(function () {
  var CSS = '.eyes-rig{display:flex;justify-content:center;align-items:center}' +
    '.eyes-rig .eye{position:relative;border-radius:50%;overflow:hidden;flex:none}' +
    '.eyes-rig .pupil{position:absolute;left:50%;top:50%;border-radius:50%;' +
    'transition:transform .08s linear;will-change:transform}' +
    '.eyes-rig.eyes-pinned{position:fixed;pointer-events:none;filter:drop-shadow(0 2px 4px rgba(0,0,0,.35))}' +
    '@media (prefers-reduced-motion:reduce){.eyes-rig .pupil{transition:none}}';
  if (!document.getElementById('eyes-rig-css')) {
    var s = document.createElement('style'); s.id = 'eyes-rig-css'; s.textContent = CSS;
    document.head.appendChild(s);
  }
  var pupils = [];
  function build(host) {
    if (host.__eyes) return; host.__eyes = true;
    var d = host.dataset, size = +d.eyeSize || 200, ratio = +d.pupilRatio || 0.38;
    var p = size * ratio;
    host.classList.add('eyes-rig'); host.style.gap = (d.gap || 8) + 'px';
    host.setAttribute('aria-hidden', 'true');
    if (d.corner) {
      var c = d.corner.split('-'), st = host.style;
      host.classList.add('eyes-pinned'); st.zIndex = d.z || 25;
      st[c[0] === 'top' ? 'top' : 'bottom'] = d.offsetY || '16px';
      st[c[1] === 'left' ? 'left' : 'right'] = d.offsetX || '16px';
    }
    for (var i = 0; i < 2; i++) {
      var eye = document.createElement('div'); eye.className = 'eye';
      eye.style.cssText = 'width:' + size + 'px;height:' + size + 'px;background:' + (d.eyeColor || 'var(--eye-white,#FAF0DF)');
      var pu = document.createElement('div'); pu.className = 'pupil';
      pu.style.cssText = 'width:' + p + 'px;height:' + p + 'px;margin:' + (-p / 2) + 'px 0 0 ' + (-p / 2) + 'px;background:' + (d.pupilColor || 'var(--pupil,#000)');
      eye.appendChild(pu); host.appendChild(eye);
      pupils.push({ eye: eye, pupil: pu, max: (size - p) / 2 - 2 });
    }
  }
  function look(x, y) {
    pupils.forEach(function (o) {
      var r = o.eye.getBoundingClientRect();
      var dx = x - (r.left + r.width / 2), dy = y - (r.top + r.height / 2);
      var dist = Math.hypot(dx, dy), k = dist > o.max ? o.max / dist : 1;
      o.pupil.style.transform = 'translate(' + dx * k + 'px,' + dy * k + 'px)';
    });
  }
  function init() {
    document.querySelectorAll('[data-eyes]').forEach(build);
    addEventListener('pointermove', function (e) { look(e.clientX, e.clientY); }, { passive: true });
    addEventListener('touchmove', function (e) { var t = e.touches[0]; if (t) look(t.clientX, t.clientY); }, { passive: true });
  }
  window.Eyes = { mount: function (el) { build(el); } };
  document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', init) : init();
})();