← Attention

Peekaboo

The element creeps up from an edge, stops halfway as if checking the coast is clear, pops up, looks left and right, then ducks back out of sight.

Anyone there?

How to use it

  1. Paste the snippet just before </head>.
  2. Put the element inside a box with overflow: hidden, against the edge it should peek from. Where you place it is where it peeks up to.
  3. Add data-peekaboo to it. It peeks once when the box scrolls into view; add data-loop to keep peeking.

Options

Add any of these attributes to the same element.

data-edgeThe edge it hides behind: bottom, top, left or right. Default bottom.
data-angleHow far it tilts as it looks around, in degrees. Default 12.
data-durationHow long one peek takes. Default 2600 ms.
data-delayWait before the first peek. Default 0 ms.
data-loopPeek again every so often while it’s on screen.
data-everyHow long it stays hidden between peeks, when looping. Default 1400 ms.
data-hoverAlso peek when the pointer moves over the box or it’s tapped.

While it looks around, the element gets a CSS variable --pkb-look from -1 (left) to 1 (right), so its eyes can follow: .eyes { transform: translateX(calc(var(--pkb-look, 0) * 3px)) }. Call animacious.peekaboo(el) to make it peek from your own code.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Peekaboo: the element peeks up from an edge, looks left and right, then
    // ducks back out of sight. Put it inside a parent that clips its overflow,
    // sitting against the edge it should peek from; where you place it is where
    // it peeks up to.
    // Usage: <div style="overflow:hidden"> … <span data-peekaboo>…</span></div>
    // Optional: data-edge ("bottom", default, "top", "left" or "right"),
    // data-angle (degrees it tilts as it looks around, default 12),
    // data-duration (ms for one peek, default 2600), data-delay (ms before the
    // first peek), data-loop (peek again every so often while in view),
    // data-every (ms out of sight between peeks, default 1400), data-hover
    // (also peek on hover or tap of its parent).
    // While it looks around the element gets a CSS variable --pkb-look, from -1
    // (looking left) to 1 (looking right), so its eyes can follow:
    //   .eyes { transform: translateX(calc(var(--pkb-look, 0) * 3px)); }
    // Script use: animacious.peekaboo(el) makes it peek from code.
    var DEFAULTS = { edge: 'bottom', angle: 12, duration: 2600, delay: 0, every: 1400 };

    function on(v) { return v !== undefined && v !== 'false'; }
    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    // How far the element has to move to be fully out of sight past its edge.
    function hidden(el) {
      var e = el.dataset.edge || DEFAULTS.edge;
      var r = el.getBoundingClientRect();
      var p = el.parentElement.getBoundingClientRect();
      var t = el._pkb.t;
      if (e === 'top') return -(r.bottom - t - p.top) - 4;
      if (e === 'left') return -(r.right - t - p.left) - 4;
      if (e === 'right') return p.right - (r.left - t) + 4;
      return p.bottom - (r.top - t) + 4;
    }

    function draw(el, p) {
      var e = el.dataset.edge || DEFAULTS.edge;
      var a = el.dataset.angle === undefined ? DEFAULTS.angle : +el.dataset.angle;
      var horiz = e === 'left' || e === 'right';
      el._pkb.t = p.t;
      el.style.transform = (horiz ? 'translateX(' : 'translateY(') + p.t.toFixed(2) + 'px) rotate(' + (p.look * a).toFixed(2) + 'deg)';
      el.style.setProperty('--pkb-look', p.look.toFixed(3));
    }

    function setup(el) {
      var e = el.dataset.edge || DEFAULTS.edge;
      el._pkb = { t: 0 };
      el.style.transformOrigin = { bottom: '50% 100%', top: '50% 0', left: '0 50%', right: '100% 50%' }[e] || '50% 100%';
      if (getComputedStyle(el).display === 'inline') el.style.display = 'inline-block';
      if (!reduced()) draw(el, { t: hidden(el), look: 0 });
    }

    function play(el, done) {
      var s = el._pkb;
      if (s.busy) { if (done) s.after = done; return; }
      if (reduced()) { if (done) done(); return; }
      s.busy = true;
      var d = el.dataset;
      var out = hidden(el);
      var p = { t: out, look: 0 };
      function finish() {
        draw(el, p);
        s.busy = false;
        var after = s.after;
        s.after = null;
        if (done) done();
        if (after) after();
      }
      var u = (+d.duration || DEFAULTS.duration) / 26;
      anime.createTimeline({ onUpdate: function () { draw(el, p); }, onComplete: finish })
        // A cautious half peek, a pause, then all the way up.
        .add(p, { t: out * 0.55, duration: u * 3, ease: 'out(3)' })
        .add(p, { t: out * 0.5, duration: u * 2, ease: 'inOut(2)' })
        .add(p, { t: 0, duration: u * 3, ease: 'outBack(1.6)' })
        // Look one way, then the other, then straight ahead.
        .add(p, { look: -1, duration: u * 3, ease: 'inOut(3)' }, '+=' + u * 1.5)
        .add(p, { look: 1, duration: u * 4, ease: 'inOut(3)' }, '+=' + u * 2)
        .add(p, { look: 0, duration: u * 2.5, ease: 'inOut(3)' }, '+=' + u * 2)
        // And duck back down, quick.
        .add(p, { t: out, duration: u * 2.5, ease: 'in(3)' }, '+=' + u * 0.5);
    }

    function cycle(el) {
      var s = el._pkb;
      play(el, function () {
        if (!on(el.dataset.loop)) return;
        clearTimeout(s.timer);
        s.timer = setTimeout(function () {
          if (s.visible) cycle(el);
          else s.waiting = true;
        }, +el.dataset.every || DEFAULTS.every);
      });
    }

    function init() {
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          var el = e.target._pkbEl;
          var s = el._pkb;
          s.visible = e.isIntersecting;
          if (!e.isIntersecting) return;
          if (!s.started) {
            s.started = true;
            setTimeout(function () { cycle(el); }, +el.dataset.delay || DEFAULTS.delay);
          } else if (s.waiting) {
            s.waiting = false;
            cycle(el);
          }
        });
      }, { threshold: 0.2 });
      document.querySelectorAll('[data-peekaboo]').forEach(function (el) {
        if (el._pkb || !el.parentElement) return;
        setup(el);
        // Watch the parent: the element itself starts out of sight.
        var box = el.parentElement;
        box._pkbEl = el;
        if (on(el.dataset.hover)) {
          box.addEventListener('pointerenter', function () { play(el); });
          box.addEventListener('pointerdown', function () { play(el); });
        }
        if (!reduced()) io.observe(box);
      });
      window.addEventListener('resize', function () {
        document.querySelectorAll('[data-peekaboo]').forEach(function (el) {
          if (el._pkb && !el._pkb.busy && !reduced()) draw(el, { t: hidden(el), look: 0 });
        });
      });
    }

    window.animacious = window.animacious || {};
    window.animacious.peekaboo = function (el) { if (el._pkb) play(el); };
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div class="panel" style="position: relative; overflow: hidden">
  <img class="critter" src="critter.svg" alt="" data-peekaboo data-loop
       style="position: absolute; bottom: 0; left: 40%">
</div>