← Seasonal

Eyes in the Dark

Glowing pairs of eyes open here and there in the dark, glance about, blink and shut again, only to open somewhere else. Move the pointer over them, or tap, and they all turn to look at you.

Who’s there?

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-eyes-in-the-dark to any section with a dark background. The eyes are drawn behind its content.

Options

Add any of these attributes to the same element.

data-countHow many pairs are open at once. Default scales with the section, 2 to 10.
data-colorsComma-separated eye colours. Default yellow, green, orange and red.
data-shapecat (slit pupils) or round. Default cat.
data-follow="false"Don’t look at the pointer.

The eyes keep away from the middle, where a heading usually sits, and further ones are smaller and dimmer. They pause when the section is off screen. With reduced motion a few pairs stare, unblinking.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Eyes in the Dark: glowing pairs of eyes open here and there in the
    // dark, glance about, blink, and shut again, only to open somewhere else.
    // Move the pointer over the section (or tap it) and they all turn to
    // look at you. Drawn behind your content.
    // Usage: <section data-eyes-in-the-dark>…</section>
    // It looks best on a very dark background.
    // Optional: data-count (pairs open at once, default scales with the
    // section), data-colors (comma-separated eye colours, default yellow,
    // green, orange and red), data-shape (cat or round, default cat),
    // data-follow="false" (don't look at the pointer).
    var DEFAULTS = { colors: ['#facc15', '#a3e635', '#fb923c', '#f87171'], shape: 'cat' };
    var uid = 0;

    var css =
      '[data-eyes-in-the-dark]{position:relative;isolation:isolate;overflow:hidden}' +
      '.eid-layer{position:absolute;inset:0;width:100%;height:100%;z-index:-1;pointer-events:none;display:block}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    var NS = 'http://www.w3.org/2000/svg';
    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
    function f(n) { return (+n).toFixed(2); }
    function rnd(a, b) { return a + Math.random() * (b - a); }

    function build(el) {
      var d = el.dataset;
      var g = 'eid' + (++uid);
      var colors = d.colors ? d.colors.split(',').map(function (c) { return c.trim(); }) : DEFAULTS.colors;
      var round = (d.shape || DEFAULTS.shape) === 'round';
      var follow = d.follow !== 'false';
      var fixedCount = d.count !== undefined ? Math.max(1, Math.min(30, Math.round(+d.count))) : 0;

      var svg = document.createElementNS(NS, 'svg');
      svg.setAttribute('class', 'eid-layer');
      svg.setAttribute('aria-hidden', 'true');
      svg.setAttribute('focusable', 'false');
      var defs = '';
      colors.forEach(function (c, i) {
        defs += '<radialGradient id="' + g + '-e' + i + '" cx=".5" cy=".55" r=".6"><stop offset="0" stop-color="#fffbe6"/><stop offset=".35" stop-color="' + c + '"/><stop offset="1" stop-color="' + c + '" stop-opacity=".75"/></radialGradient>' +
          '<radialGradient id="' + g + '-h' + i + '"><stop offset="0" stop-color="' + c + '" stop-opacity=".35"/><stop offset="1" stop-color="' + c + '" stop-opacity="0"/></radialGradient>';
      });
      svg.innerHTML = '<defs>' + defs + '</defs>';
      el.insertBefore(svg, el.firstChild);

      var W = 0, H = 0;
      function layout() {
        W = el.clientWidth; H = el.clientHeight;
        svg.setAttribute('viewBox', '0 0 ' + W + ' ' + H);
      }
      layout();

      var pupil = round ? '<circle class="eid-p" r="3.6" fill="#0a0a0a"/>' : '<ellipse class="eid-p" rx="1.7" ry="5.6" fill="#0a0a0a"/>';
      function eye(ci, sx) {
        return '<g transform="translate(' + (sx * 13) + ' 0)"><g class="eid-lid">' +
          '<path d="M-10 0 Q-1 -10 10 -1 Q1 9 -10 0 Z" fill="url(#' + g + '-e' + ci + ')" transform="scale(' + sx + ' 1)"/>' +
          pupil + '<circle cx="-3" cy="-2.5" r="1.3" fill="#fff" opacity=".8"/></g></g>';
      }

      var pairs = [];
      function target() { return fixedCount || Math.max(2, Math.min(10, Math.round(W * H / 30000))); }

      // Pick a spot that isn't too close to another pair, and keeps clear of
      // the middle where a heading usually sits, if it can.
      function spot(sz) {
        var best = null, bestScore = -1;
        for (var tries = 0; tries < 24; tries++) {
          var x = rnd(sz * 1.6, W - sz * 1.6), y = rnd(sz * 1.2, H - sz * 1.2);
          var near = Infinity;
          pairs.forEach(function (p) { near = Math.min(near, Math.hypot((p.x - x) / 1.6, p.y - y)); });
          var dx = (x - W / 2) / (W * 0.3), dy = (y - H / 2) / (H * 0.2);
          var score = Math.min(near, 400) * (dx * dx + dy * dy < 1 ? 0.25 : 1);
          if (score > bestScore) { bestScore = score; best = { x: x, y: y }; }
        }
        return best;
      }

      function addPair(still) {
        var depth = rnd(0.55, 1.1);
        var sz = Math.max(16, Math.min(40, W * 0.045)) * depth;
        var at = spot(sz * 1.4);
        var ci = Math.floor(Math.random() * colors.length);
        var gEl = document.createElementNS(NS, 'g');
        gEl.innerHTML = '<ellipse rx="40" ry="20" fill="url(#' + g + '-h' + ci + ')"/>' + eye(ci, -1) + eye(ci, 1);
        svg.appendChild(gEl);
        var p = {
          el: gEl, lids: gEl.querySelectorAll('.eid-lid'), pups: gEl.querySelectorAll('.eid-p'),
          x: at.x, y: at.y, s: sz / 26, tilt: rnd(-8, 8), dim: 0.45 + 0.55 * (depth - 0.55) / 0.55,
          lid: still ? 1 : 0, o: still ? 1 : 0, lx: 0, ly: 0, tl: null
        };
        pairs.push(p);
        draw(p);
        if (!still) life(p);
        return p;
      }

      function life(p) {
        var tl = anime.createTimeline({
          onComplete: function () {
            p.el.remove();
            pairs.splice(pairs.indexOf(p), 1);
            p.tl = null;
            spawnSoon();
          }
        });
        p.tl = tl;
        tl.add(p, { o: [0, 1], duration: 160, ease: 'linear' }, rnd(0, 300))
          .add(p, { lid: [0, 1], duration: 380, ease: 'out(3)' }, '<<');
        var at = 600 + rnd(300, 1200);
        var looks = 1 + Math.floor(Math.random() * 3);
        for (var i = 0; i < looks; i++) {
          tl.add(p, { lx: rnd(-1, 1), ly: rnd(-0.5, 0.5), duration: 260, ease: 'inOut(2)' }, at);
          at += 400 + rnd(200, 900);
          if (Math.random() < 0.75) {
            tl.add(p, { lid: [{ to: 0.06, duration: 80, ease: 'in(2)' }, { to: 1, duration: 160, ease: 'out(2)' }] }, at);
            at += 300 + rnd(200, 800);
            if (Math.random() < 0.3) { tl.add(p, { lid: [{ to: 0.06, duration: 70 }, { to: 1, duration: 140 }] }, at); at += 400; }
          }
        }
        tl.add(p, { lid: 0, duration: 200, ease: 'in(2)' }, at)
          .add(p, { o: 0, duration: 120, ease: 'linear' }, at + 140);
      }

      var waiting = 0;
      function spawnSoon() {
        if (pairs.length + waiting >= target()) return;
        waiting++;
        setTimeout(function () {
          waiting--;
          if (visible) addPair(); else spawnSoon();
        }, rnd(200, 1400));
      }

      var pointer = null, lookT = 0;
      function draw(p) {
        var lx = p.lx, ly = p.ly;
        if (pointer && follow) {
          lx = Math.max(-1, Math.min(1, (pointer.x - p.x) / (W * 0.25)));
          ly = Math.max(-1, Math.min(1, (pointer.y - p.y) / (H * 0.35)));
        }
        p.el.setAttribute('transform', 'translate(' + f(p.x) + ' ' + f(p.y) + ') rotate(' + f(p.tilt) + ') scale(' + f(p.s) + ')');
        p.el.setAttribute('opacity', f(p.o * p.dim));
        p.lids.forEach(function (l) { l.setAttribute('transform', 'scale(1 ' + f(Math.max(0.02, p.lid)) + ')'); });
        p.pups.forEach(function (pu) { pu.setAttribute('transform', 'translate(' + f(lx * 4.5) + ' ' + f(ly * 2.2) + ')'); });
      }

      if (reduced()) {
        for (var i = 0; i < target(); i++) addPair(true);
        return;
      }

      if (follow) {
        el.addEventListener('pointermove', function (e) {
          var r = el.getBoundingClientRect();
          pointer = { x: e.clientX - r.left, y: e.clientY - r.top };
          lookT = e.pointerType === 'mouse' ? Infinity : 1600;
        });
        el.addEventListener('pointerdown', function (e) {
          var r = el.getBoundingClientRect();
          pointer = { x: e.clientX - r.left, y: e.clientY - r.top };
          lookT = 1600;
        });
        el.addEventListener('pointerleave', function () { pointer = null; });
      }

      var visible = false, last = 0;
      var timer = anime.createTimer({
        loop: true, duration: 1000, autoplay: false,
        onUpdate: function () {
          var now = performance.now();
          var dt = last ? Math.min(50, now - last) : 16;
          last = now;
          if (pointer && lookT !== Infinity) { lookT -= dt; if (lookT <= 0) pointer = null; }
          pairs.forEach(draw);
        }
      });
      var resizeT = 0;
      if (window.ResizeObserver) new ResizeObserver(function () {
        clearTimeout(resizeT);
        resizeT = setTimeout(function () {
          layout();
          pairs.forEach(function (p) { p.x = Math.min(p.x, W - 20); p.y = Math.min(p.y, H - 15); });
          for (var i = pairs.length; i < target(); i++) spawnSoon();
        }, 150);
      }).observe(el);

      var started = false;
      new IntersectionObserver(function (entries) {
        visible = entries[0].isIntersecting;
        if (visible) {
          last = 0; timer.play();
          pairs.forEach(function (p) { if (p.tl) p.tl.play(); });
          if (!started) {
            started = true;
            layout();
            var n = target();
            for (var i = 0; i < n; i++) (function (i) { setTimeout(function () { addPair(); }, i * rnd(150, 400)); })(i);
          }
        } else {
          timer.pause();
          pairs.forEach(function (p) { if (p.tl) p.tl.pause(); });
        }
      }, { threshold: 0.2 }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-eyes-in-the-dark]').forEach(function (el) {
        if (el._eid) return;   // idempotent
        el._eid = true;
        build(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<section data-eyes-in-the-dark style="background: #030712">
  <h2>Who's there?</h2>
</section>

<!-- round, all yellow, a crowd of them: -->
<section data-eyes-in-the-dark data-shape="round" data-colors="#fde047" data-count="12">…</section>