← Illustrations

UFO Beam

A flying saucer hovers over the hills at night, lights chasing round its rim. It switches on its tractor beam and the missing page floats up off the ground, wobbling, and vanishes inside. Then a new page drops in and it happens all over again.

How to use it

  1. Paste the snippet just before </head>.
  2. Add an empty <div data-ufo-beam> where the scene should go. It fills the element with an SVG scene.

Options

Add any of these attributes to the same element.

data-textWhat’s written on the page. Default 404.
data-colorColour of the beam. Default #a7f3d0.
data-durationTime for one abduction. Default 5600 ms.

It is 8:5 with a night-sky background by default; set your own height or background on the element to change that. It pauses when off screen. With reduced motion it shows the page halfway up the beam.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // UFO Beam: a flying saucer hovers over the hills at night, its lights
    // chasing round the rim. It switches on a tractor beam and the missing
    // page floats up off the ground, wobbling, and vanishes inside. Then a
    // new page drops in and it all happens again. Drawn as SVG that fills
    // the element, and paused when off screen.
    // Usage: <div data-ufo-beam></div>
    // It is 8:5 by default with a night-sky background; give it your own
    // height or background to change that.
    // Optional: data-color (beam colour, default #a7f3d0), data-duration
    // (ms per abduction, default 5600), data-text (written on the page,
    // default "404").
    var DEFAULTS = { color: '#a7f3d0', duration: 5600, text: '404' };
    var uid = 0;

    var css =
      '[data-ufo-beam]{display:block;position:relative;overflow:hidden;width:100%;aspect-ratio:8/5;' +
      'background:linear-gradient(#0c0a26 0%,#24124d 70%,#3b1d6e 100%)}' +
      '.ufb-svg{position:absolute;inset:0;width:100%;height:100%;display:block;overflow:visible}' +
      '.ufb-txt{font:800 9px/1 system-ui,-apple-system,"Segoe UI",sans-serif}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
    function clamp01(v) { return v < 0 ? 0 : v > 1 ? 1 : v; }
    function seg(t, a, b) { return clamp01((t - a) / (b - a)); }
    function inOut(k) { return k < 0.5 ? 2 * k * k : 1 - Math.pow(-2 * k + 2, 2) / 2; }
    function inQ(k) { return k * k * k; }
    function outBounce(k) {
      var n = 7.5625, dd = 2.75;
      if (k < 1 / dd) return n * k * k;
      if (k < 2 / dd) return n * (k -= 1.5 / dd) * k + 0.75;
      if (k < 2.5 / dd) return n * (k -= 2.25 / dd) * k + 0.9375;
      return n * (k -= 2.625 / dd) * k + 0.984375;
    }

    function build(el) {
      var d = el.dataset;
      var id = 'ufb' + (++uid);
      var color = d.color || DEFAULTS.color;
      var cycle = +d.duration || DEFAULTS.duration;
      if (!el.hasAttribute('aria-label')) el.setAttribute('aria-label', 'A UFO beaming up the missing page');
      if (!el.hasAttribute('role')) el.setAttribute('role', 'img');

      var stars = '';
      for (var i = 0; i < 80; i++) {
        stars += '<circle class="ufb-star" cx="' + (Math.random() * 1400 - 500).toFixed(1) + '" cy="' + (Math.random() * 300 - 130).toFixed(1) +
          '" r="' + (Math.random() * 0.9 + 0.4).toFixed(2) + '" fill="#fff" opacity="' + (Math.random() * 0.6 + 0.2).toFixed(2) + '"/>';
      }
      var lights = '';
      for (var j = 0; j < 7; j++) {
        var a = Math.PI * (0.12 + 0.76 * j / 6);
        lights += '<circle class="ufb-light" cx="' + (-Math.cos(a) * 50).toFixed(1) + '" cy="' + (4 + Math.sin(a) * 6).toFixed(1) + '" r="3" fill="#fde68a"/>';
      }
      var motes = '';
      for (var m = 0; m < 10; m++) motes += '<circle class="ufb-mote" r="' + (1 + Math.random()).toFixed(1) + '" fill="' + color + '"/>';

      el.innerHTML =
        '<svg class="ufb-svg" viewBox="0 0 400 250" preserveAspectRatio="xMidYMid meet" aria-hidden="true" focusable="false">' +
        '<defs>' +
        '<linearGradient id="' + id + '-beam" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="' + color + '" stop-opacity=".75"/><stop offset="1" stop-color="' + color + '" stop-opacity=".12"/></linearGradient>' +
        '<linearGradient id="' + id + '-hull" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#ddd6fe"/><stop offset="1" stop-color="#7c3aed"/></linearGradient>' +
        '<radialGradient id="' + id + '-spot"><stop offset="0" stop-color="' + color + '" stop-opacity=".7"/><stop offset="1" stop-color="' + color + '" stop-opacity="0"/></radialGradient>' +
        '</defs>' +
        stars +
        '<path d="M-800 205Q-200 170 100 200T400 196T900 190L1200 210V700H-800Z" fill="#1d1040"/>' +
        '<path d="M-800 222Q0 205 200 218T1200 215V700H-800Z" fill="#140a2e"/>' +
        '<ellipse class="ufb-spot" cx="200" cy="222" rx="70" ry="10" fill="url(#' + id + '-spot)" opacity="0"/>' +
        '<g class="ufb-hover">' +
          '<path class="ufb-beam" d="M178 92L218 92L268 222L128 222Z" fill="url(#' + id + '-beam)" opacity="0"/>' +
          '<g class="ufb-motes">' + motes + '</g>' +
        '</g>' +
        // The page, drawn around its centre.
        '<g class="ufb-page">' +
          '<path d="M-15 -20H8L15 -13V20H-15Z" fill="#fff"/><path d="M8 -20V-13H15Z" fill="#c4b5fd"/>' +
          '<text class="ufb-txt" x="0" y="-2" text-anchor="middle" fill="#6a0dad"></text>' +
          '<rect x="-9" y="5" width="18" height="2.5" rx="1.2" fill="#ddd6fe"/><rect x="-9" y="11" width="12" height="2.5" rx="1.2" fill="#ddd6fe"/>' +
        '</g>' +
        '<g class="ufb-hover2"><g transform="translate(198 80)"><g class="ufb-ship">' +
          '<path d="M-24 -2A24 22 0 0 1 24 -2Z" fill="#a5f3fc" opacity=".55"/>' +
          '<circle cx="0" cy="-10" r="7" fill="#86efac"/><circle cx="-2.5" cy="-11" r="1.8" fill="#14532d"/><circle cx="3" cy="-11" r="1.8" fill="#14532d"/>' +
          '<path d="M-20 -6A20 16 0 0 1 -6 -18" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" opacity=".6"/>' +
          '<ellipse cx="0" cy="12" rx="22" ry="6" fill="#4c1d95"/>' +
          '<ellipse cx="0" cy="3" rx="60" ry="13" fill="url(#' + id + '-hull)"/>' +
          '<ellipse cx="0" cy="0" rx="40" ry="5" fill="#fff" opacity=".25"/>' +
          lights +
        '</g></g></g>' +
        '</svg>';

      var svg = el.firstChild;
      svg.querySelector('.ufb-txt').textContent = d.text || DEFAULTS.text;
      var hover = svg.querySelector('.ufb-hover'), hover2 = svg.querySelector('.ufb-hover2');
      var ship = svg.querySelector('.ufb-ship');
      var beam = svg.querySelector('.ufb-beam'), spot = svg.querySelector('.ufb-spot');
      var page = svg.querySelector('.ufb-page');
      var lightEls = svg.querySelectorAll('.ufb-light');
      var moteEls = Array.prototype.map.call(svg.querySelectorAll('.ufb-mote'), function (c, i) {
        return { el: c, off: i / 10, x: (Math.random() - 0.5) * 2 };
      });

      function frame(t, now) {
        var k = t % cycle;
        var bob = Math.sin(now / 700) * 5, tilt = Math.sin(now / 1100) * 3;
        hover.setAttribute('transform', 'translate(0 ' + bob.toFixed(2) + ')');
        hover2.setAttribute('transform', 'translate(0 ' + bob.toFixed(2) + ')');
        ship.setAttribute('transform', 'rotate(' + tilt.toFixed(2) + ')');
        // Lights chase round the rim.
        for (var i = 0; i < lightEls.length; i++) {
          var on = (Math.floor(now / 140) - i) % lightEls.length;
          lightEls[i].setAttribute('opacity', on === 0 || on === -lightEls.length ? 1 : 0.35 + 0.15 * ((i + Math.floor(now / 140)) % 2));
        }
        // Beam on (flickering up), page rises, beam off.
        var u = cycle / 5600;
        var beamOn = seg(k, 900 * u, 1150 * u) * (1 - seg(k, 4100 * u, 4400 * u));
        var flick = beamOn > 0 && beamOn < 1 ? (Math.floor(now / 50) % 2 ? 0.4 : 1) : 1;
        beam.setAttribute('opacity', (beamOn * flick * (0.85 + Math.sin(now / 90) * 0.08)).toFixed(3));
        spot.setAttribute('opacity', (beamOn * flick).toFixed(3));
        // Page: drops in, waits, then floats up into the ship.
        var drop = outBounce(seg(k, 0, 700 * u));
        var lift = inOut(seg(k, 1400 * u, 3900 * u));
        var gulp = inQ(seg(k, 3500 * u, 3950 * u));
        var py = -80 + drop * 280 - lift * 112 + bob * lift;
        var wob = Math.sin(k / 220) * 14 * lift * (1 - gulp);
        var sc = 1 - lift * 0.45 - gulp * 0.5;
        var px = 198 + Math.sin(k / 330) * 8 * lift * (1 - gulp);
        page.setAttribute('transform', 'translate(' + px.toFixed(2) + ' ' + py.toFixed(2) + ') rotate(' + wob.toFixed(2) + ') scale(' + Math.max(0.01, sc).toFixed(3) + ')');
        page.setAttribute('opacity', k > 3950 * u ? 0 : 1);
        // Little motes rise in the beam.
        for (var j = 0; j < moteEls.length; j++) {
          var mo = moteEls[j], p = (now / 1800 + mo.off) % 1;
          var w = 50 - p * 30;
          mo.el.setAttribute('cx', (198 + mo.x * w * 0.6 + Math.sin(now / 300 + j) * 3).toFixed(1));
          mo.el.setAttribute('cy', (220 - p * 120).toFixed(1));
          mo.el.setAttribute('opacity', (beamOn * Math.sin(p * Math.PI)).toFixed(3));
        }
      }

      if (reduced()) {
        // A still frame: beam on, page halfway up.
        frame(2600 * cycle / 5600, 0);
        return;
      }

      var t = 0, now = 0, last = 0;
      var timer = anime.createTimer({
        loop: true,
        duration: 1000,
        autoplay: false,
        onUpdate: function () {
          var p = performance.now();
          var dt = last ? Math.min(50, p - last) : 16;
          last = p;
          t += dt; now += dt;
          frame(t, now);
        }
      });
      frame(0, 0);
      var tw = anime.animate(svg.querySelectorAll('.ufb-star'), {
        opacity: function () { return anime.utils.random(0.05, 0.9, 2); },
        duration: function () { return anime.utils.random(1200, 3400); },
        delay: function () { return anime.utils.random(0, 2000); },
        ease: 'inOutSine',
        loop: true,
        alternate: true,
        autoplay: false
      });
      // Pause while off screen to save battery.
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) { last = 0; timer.play(); tw.play(); } else { timer.pause(); tw.pause(); }
      }).observe(el);
    }

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

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-ufo-beam></div>
<h1>Page abducted</h1>
<p>We&rsquo;re negotiating its return.</p>