← Seasonal

Floating Ghost

A friendly little ghost drifts about, bobbing up and down with its hem rippling behind it. Every so often it fades almost to nothing, then shimmers back. It blinks, too.

How to use it

  1. Paste the snippet just before </head>.
  2. Add an empty <div data-floating-ghost> where the ghost should haunt.

Options

Add any of these attributes to the same element.

data-wander="false"Bob in place in the middle instead of drifting about.
data-fade="false"Never fade out.
data-colorBody colour. Default #f8fafc.
data-speedSpeed multiplier. Default 1.

It is 8:5 with a transparent background by default; set your own height or background on the element. It roams as wide as the element allows and pauses when off screen. With reduced motion it stays still in the middle.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Floating Ghost: a friendly little ghost drifts about, bobbing up and
    // down with its hem rippling behind it. Every so often it fades almost to
    // nothing, then shimmers back. It blinks, too. Drawn as SVG that fills the
    // element, and paused when off screen.
    // Usage: <div data-floating-ghost></div>
    // It is 8:5 by default with a transparent background; give it your own
    // height or background to change that.
    // Optional: data-wander="false" (bob in place in the middle instead of
    // drifting about), data-fade="false" (never fade out), data-color (body
    // colour, default #f8fafc), data-speed (default 1).
    var DEFAULTS = { color: '#f8fafc', speed: 1 };
    var uid = 0;

    var css =
      '[data-floating-ghost]{display:block;position:relative;overflow:hidden;width:100%;aspect-ratio:8/5}' +
      '.fgh-svg{position:absolute;inset:0;width:100%;height:100%;display:block}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    // The ghost's outline, centred on (0, 0): a dome on top and a hem of
    // scallops that ripple with time s. lag pulls the hem back as it moves.
    function body(s, lag) {
      var d = 'M-28 8C-28 -24 -15 -38 0 -38C15 -38 28 -24 28 8L' + (28 + lag * 0.6).toFixed(2) + ' 30';
      var n = 4, wdt = 56 / n;
      for (var k = 0; k < n; k++) {
        var x0 = 28 - k * wdt, x1 = x0 - wdt, xm = (x0 + x1) / 2;
        var ph = s * 4.2 + k * 1.3;
        var y1 = 30 + Math.sin(ph + 1.3) * 2.6;
        var ym = 40 + Math.sin(ph + 0.6) * 3.2;
        var l = lag * (0.6 + (k + 1) / n * 0.4);
        d += 'Q' + (xm + l).toFixed(2) + ' ' + ym.toFixed(2) + ' ' + (x1 + l).toFixed(2) + ' ' + (k === n - 1 ? 30 : y1).toFixed(2);
      }
      return d + 'Z';
    }

    function build(el) {
      var d = el.dataset;
      var n = ++uid;
      var wander = d.wander !== 'false';
      var fade = d.fade !== 'false';
      var color = d.color || DEFAULTS.color;
      var speed = d.speed !== undefined ? +d.speed : DEFAULTS.speed;
      if (!el.hasAttribute('aria-label')) el.setAttribute('aria-label', 'A little ghost floating about');
      if (!el.hasAttribute('role')) el.setAttribute('role', 'img');

      var g = 'fgh' + n;
      el.innerHTML =
        '<svg class="fgh-svg" viewBox="0 0 400 250" preserveAspectRatio="xMidYMid meet" aria-hidden="true" focusable="false">' +
        '<defs>' +
        '<radialGradient id="' + g + '-aura"><stop offset="0" stop-color="' + color + '" stop-opacity=".35"/><stop offset="1" stop-color="' + color + '" stop-opacity="0"/></radialGradient>' +
        '<linearGradient id="' + g + '-shade" x1="0" y1="0" x2="1" y2="1"><stop offset=".5" stop-color="#94a3b8" stop-opacity="0"/><stop offset="1" stop-color="#94a3b8" stop-opacity=".4"/></linearGradient>' +
        '</defs>' +
        '<ellipse class="fgh-shadow" cx="200" cy="226" rx="30" ry="5" fill="#000" opacity=".2"/>' +
        '<g class="fgh-ghost">' +
          '<circle r="66" fill="url(#' + g + '-aura)"/>' +
          '<g class="fgh-arms" fill="' + color + '">' +
            '<ellipse class="fgh-arm-l" cx="-27" cy="4" rx="9" ry="4.6"/>' +
            '<ellipse class="fgh-arm-r" cx="27" cy="4" rx="9" ry="4.6"/>' +
          '</g>' +
          '<path class="fgh-body" fill="' + color + '"/>' +
          '<path class="fgh-body-shade" fill="url(#' + g + '-shade)"/>' +
          '<g class="fgh-face">' +
            '<g class="fgh-eyes"><ellipse cx="-9" cy="-14" rx="4" ry="5.4" fill="#1e1b2e"/><ellipse cx="9" cy="-14" rx="4" ry="5.4" fill="#1e1b2e"/>' +
            '<circle cx="-7.8" cy="-16" r="1.4" fill="#fff"/><circle cx="10.2" cy="-16" r="1.4" fill="#fff"/></g>' +
            '<ellipse cx="0" cy="-2" rx="3.4" ry="4.4" fill="#1e1b2e"/>' +
            '<ellipse cx="-17" cy="-5" rx="4" ry="2.2" fill="#f9a8d4" opacity=".55"/><ellipse cx="17" cy="-5" rx="4" ry="2.2" fill="#f9a8d4" opacity=".55"/>' +
          '</g>' +
        '</g>' +
        '</svg>';

      var svg = el.firstChild;
      var ghost = svg.querySelector('.fgh-ghost');
      var path = svg.querySelector('.fgh-body');
      var shade = svg.querySelector('.fgh-body-shade');
      var eyes = svg.querySelector('.fgh-eyes');
      var armL = svg.querySelector('.fgh-arm-l'), armR = svg.querySelector('.fgh-arm-r');
      var shadow = svg.querySelector('.fgh-shadow');

      // How far it may roam across the scene, in viewBox units: as wide as the
      // element shows, less a margin.
      var roam = 120;
      function fit() {
        var aspect = (el.clientWidth || 400) / (el.clientHeight || 250);
        roam = Math.min(160, Math.max(200, 125 * aspect) - 70);
      }
      fit();
      if (window.ResizeObserver) new ResizeObserver(fit).observe(el);

      var t = 0, prevX = 200, lag = 0, blinkAt = 2.5;
      function frame(dt) {
        t += dt * speed;
        var s = t / 1000;
        var x = 200, y = 108 + Math.sin(s * 2.1) * 9;
        if (wander) {
          x = 200 + Math.sin(s * 0.42) * (roam - 10) * 0.85 + Math.sin(s * 0.97 + 1) * roam * 0.15;
          y += Math.sin(s * 0.61 + 2) * 18;
        }
        var vx = dt ? (x - prevX) / dt * 1000 : 0;
        prevX = x;
        lag += (-vx * 0.22 - lag) * Math.min(1, dt / 160);
        lag = Math.max(-9, Math.min(9, lag));
        var tilt = Math.max(-10, Math.min(10, vx * 0.12)) + Math.sin(s * 1.6) * 3;

        // Fade: mostly there, now and then fading to a wisp and back.
        var op = 1;
        if (fade) {
          var c = (s % 9) / 9;
          if (c > 0.62 && c < 0.94) op = 1 - 0.88 * Math.sin((c - 0.62) / 0.32 * Math.PI);
        }
        ghost.setAttribute('transform', 'translate(' + x.toFixed(2) + ' ' + y.toFixed(2) + ') rotate(' + tilt.toFixed(2) + ') scale(1.3)');
        ghost.setAttribute('opacity', op.toFixed(3));
        var dPath = body(s, lag);
        path.setAttribute('d', dPath);
        shade.setAttribute('d', dPath);
        armL.setAttribute('transform', 'rotate(' + (Math.sin(s * 3.1) * 16 - 10).toFixed(1) + ' -22 4)');
        armR.setAttribute('transform', 'rotate(' + (Math.sin(s * 3.1 + 1.4) * 16 + 10).toFixed(1) + ' 22 4)');

        // Blink every few seconds.
        var b = s - blinkAt;
        if (b > 0.18) blinkAt = s + 2 + Math.random() * 3;
        var lid = b > 0 && b < 0.18 ? Math.abs(b / 0.09 - 1) : 1;
        eyes.setAttribute('transform', 'translate(0 -14) scale(1 ' + Math.max(0.1, lid).toFixed(2) + ') translate(0 14)');

        // The shadow on the ground follows, smaller and fainter when higher.
        var hgt = (y - 80) / 60;
        shadow.setAttribute('cx', x.toFixed(2));
        shadow.setAttribute('rx', (22 + hgt * 10).toFixed(2));
        shadow.setAttribute('opacity', ((0.1 + hgt * 0.1) * op).toFixed(3));
      }

      if (reduced()) {
        // A still ghost in the middle, fully visible.
        wander = false; fade = false;
        frame(0);
        return;
      }

      frame(0);
      var 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;
          frame(dt);
        }
      });
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
      }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-floating-ghost]').forEach(function (el) {
        if (el._fgh) return;   // idempotent
        el._fgh = true;
        build(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-floating-ghost style="background: #1e1b4b"></div>

<!-- small, in place, always visible: -->
<div data-floating-ghost data-wander="false" data-fade="false" style="width: 200px"></div>