← Illustrations

Fish Tank

Little fish swim to and fro in an aquarium, turning round at the glass and wandering up and down, their tails wagging. Seaweed sways, light ripples down from the surface and bubbles rise from the air stone and pop at the top.

How to use it

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

Options

Add any of these attributes to the same element.

data-fishNumber of fish, 1 to 10. Default 5.
data-colorsComma-separated fish colours, used in turn. Default orange, yellow, pink, violet and teal.
data-speedSpeed multiplier. Default 1.
data-bubbles="false"No bubbles.

It is 8:5 on a transparent background by default. It pauses when off screen. With reduced motion it’s a still picture.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Fish Tank: little fish swim to and fro in an aquarium, turning round at
    // the glass and wandering up and down, while seaweed sways and bubbles
    // rise from the air stone and pop at the surface. Drawn as SVG that fills
    // the element, and paused when off screen.
    // Usage: <div data-fish-tank></div>
    // It is 8:5 by default on a transparent background.
    // Optional: data-fish (number of fish, 1 to 10, default 5), data-colors
    // (comma-separated fish colours), data-speed (default 1),
    // data-bubbles="false" (no bubbles).
    var DEFAULTS = { fish: 5, speed: 1, colors: ['#f97316', '#facc15', '#ec4899', '#8b5cf6', '#14b8a6'] };
    var NS = 'http://www.w3.org/2000/svg';
    var uid = 0;
    var L = 54, R = 346, SURFACE = 44, FLOOR = 204;

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

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
    function rand(a, b) { return a + Math.random() * (b - a); }

    function fishHTML(col, striped) {
      return '<g class="ftk-tail" transform="translate(-12 0)"><path d="M2 0L-11 -9Q-7 0 -11 9Z" fill="' + col + '"/></g>' +
        '<path d="M-3 -7Q2 -14 9 -7Z" fill="' + col + '"/>' +
        '<path d="M17 0C11 -11 -7 -11 -14 0C-7 11 11 11 17 0Z" fill="' + col + '"/>' +
        (striped ? '<path d="M3 -8.6C5 -3 5 3 3 8.6L-1 8.8C1 3 1 -3 -1 -8.8Z" fill="#fff" opacity=".75"/>' : '') +
        '<path d="M17 0C11 6 -2 9 -12 3C-4 7 10 6 17 0Z" fill="#000" opacity=".12"/>' +
        '<path d="M1 3Q5 8 9 3" fill="none" stroke="#000" stroke-opacity=".18" stroke-width="1.2" stroke-linecap="round"/>' +
        '<circle cx="9.5" cy="-2" r="2.7" fill="#fff"/><circle cx="10.3" cy="-2" r="1.4" fill="#1e1b2e"/>';
    }

    function build(el) {
      var d = el.dataset;
      var n = ++uid;
      var count = d.fish !== undefined ? Math.max(1, Math.min(10, Math.round(+d.fish))) : DEFAULTS.fish;
      var speed = d.speed !== undefined ? +d.speed : DEFAULTS.speed;
      var colors = d.colors ? d.colors.split(',').map(function (c) { return c.trim(); }) : DEFAULTS.colors;
      var bubblesOn = d.bubbles !== 'false';
      if (!el.hasAttribute('aria-label')) el.setAttribute('aria-label', 'Fish swimming in a fish tank');
      if (!el.hasAttribute('role')) el.setAttribute('role', 'img');

      var g = 'ftk' + n;
      var sand = 'M40 ' + FLOOR;
      for (var x = 40; x <= 360; x += 20) sand += 'Q' + (x + 10) + ' ' + (FLOOR - 4 + Math.sin(x * 0.05) * 3).toFixed(1) + ' ' + (x + 20) + ' ' + FLOOR;
      sand += 'L360 230L40 230Z';
      var pebbles = '';
      for (var p = 0; p < 22; p++) {
        pebbles += '<ellipse cx="' + rand(48, 352).toFixed(1) + '" cy="' + rand(FLOOR + 6, 222).toFixed(1) + '" rx="' + rand(2, 4).toFixed(1) + '" ry="' + rand(1.4, 2.6).toFixed(1) + '" fill="' + ['#d6a46b', '#a8a29e', '#fbbf24', '#c4b5fd'][p % 4] + '" opacity=".8"/>';
      }

      el.innerHTML =
        '<svg class="ftk-svg" viewBox="0 0 400 250" preserveAspectRatio="xMidYMid meet" aria-hidden="true" focusable="false">' +
        '<defs>' +
        '<clipPath id="' + g + '-clip"><rect x="40" y="22" width="320" height="208" rx="10"/></clipPath>' +
        '<linearGradient id="' + g + '-water" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#7dd3fc"/><stop offset=".6" stop-color="#38bdf8"/><stop offset="1" stop-color="#0284c7"/></linearGradient>' +
        '<linearGradient id="' + g + '-ray" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#fff" stop-opacity=".45"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient>' +
        '</defs>' +
        '<ellipse cx="200" cy="244" rx="176" ry="5" fill="#1e1b2e" opacity=".08"/>' +
        '<g clip-path="url(#' + g + '-clip)">' +
          '<rect x="40" y="22" width="320" height="208" fill="#e0f2fe"/>' +
          '<path class="ftk-water" fill="url(#' + g + '-water)"/>' +
          '<g class="ftk-rays" fill="url(#' + g + '-ray)">' +
            '<path d="M110 44L140 44L110 200L70 200Z"/><path d="M200 44L216 44L200 200L176 200Z"/><path d="M270 44L300 44L290 200L250 200Z"/>' +
          '</g>' +
          '<g class="ftk-weeds" fill="none" stroke-linecap="round"></g>' +
          '<path d="M262 ' + FLOOR + 'C262 176 296 170 300 ' + FLOOR + 'Z" fill="#64748b"/><path d="M286 ' + FLOOR + 'C290 186 316 186 318 ' + FLOOR + 'Z" fill="#94a3b8"/>' +
          '<g class="ftk-fish"></g>' +
          '<path d="' + sand + '" fill="#fde68a"/>' + pebbles +
          '<rect x="74" y="' + (FLOOR - 2) + '" width="18" height="7" rx="3" fill="#475569"/>' +
          '<g class="ftk-bubbles" fill="#fff" fill-opacity=".35" stroke="#fff" stroke-width=".8"></g>' +
          '<path class="ftk-surface" fill="none" stroke="#fff" stroke-width="1.6" opacity=".8"/>' +
        '</g>' +
        '<rect x="40" y="22" width="320" height="208" rx="10" fill="none" stroke="#cbd5e1" stroke-width="4"/>' +
        '<rect x="34" y="16" width="332" height="10" rx="4" fill="#475569"/>' +
        '<rect x="34" y="226" width="332" height="12" rx="4" fill="#475569"/>' +
        '<path d="M342 40L342 120M334 46L334 70" stroke="#fff" stroke-width="3" stroke-linecap="round" opacity=".45"/>' +
        '</svg>';

      var svg = el.firstChild;
      var water = svg.querySelector('.ftk-water');
      var surface = svg.querySelector('.ftk-surface');
      var rays = svg.querySelector('.ftk-rays');

      var weeds = [[66, 70, '#16a34a'], [104, 56, '#22c55e'], [118, 84, '#15803d'], [232, 62, '#22c55e'], [246, 92, '#16a34a'], [328, 74, '#15803d']].map(function (w, i) {
        var path = document.createElementNS(NS, 'path');
        path.setAttribute('stroke', w[2]);
        path.setAttribute('stroke-width', i % 2 ? 5 : 6);
        svg.querySelector('.ftk-weeds').appendChild(path);
        return { el: path, x: w[0], h: w[1], ph: i * 1.3 };
      });

      var fish = [];
      for (var i = 0; i < count; i++) {
        var f = document.createElementNS(NS, 'g');
        f.innerHTML = fishHTML(colors[i % colors.length], i % 3 === 1);
        svg.querySelector('.ftk-fish').appendChild(f);
        var dir = Math.random() < 0.5 ? -1 : 1;
        var y0 = SURFACE + 22 + (FLOOR - SURFACE - 52) * ((i + 0.5) / count);
        fish.push({
          el: f, tail: f.querySelector('.ftk-tail'), s: rand(0.7, 1.1),
          x: rand(L + 30, R - 30), y: y0, ty: y0, dir: dir, face: dir,
          v: rand(0.025, 0.045), ph: rand(0, 6.28), wander: rand(1000, 4000)
        });
      }

      var bubbles = [];
      if (bubblesOn) {
        for (var b = 0; b < 16; b++) {
          var c = document.createElementNS(NS, 'circle');
          svg.querySelector('.ftk-bubbles').appendChild(c);
          bubbles.push({ el: c, y: 999, wait: b * 260 });
        }
      }
      function spawnBubble(bb) {
        if (fish.length && Math.random() < 0.25) {
          // Now and then a fish blows one.
          var f = fish[Math.floor(Math.random() * fish.length)];
          bb.x = f.x + 17 * f.s * f.face; bb.y = f.y - 2; bb.r = rand(1.2, 2);
        } else {
          bb.x = 83 + rand(-4, 4); bb.y = FLOOR - 4; bb.r = rand(1.5, 3.4);
        }
        bb.v = rand(0.03, 0.05); bb.ph = rand(0, 6.28); bb.x0 = bb.x;
      }

      function frame(dt, t) {
        var s = t / 1000;
        // Surface ripple and the water body under it.
        var surf = '';
        for (var x = 40; x <= 360; x += 10) surf += (x === 40 ? 'M' : 'L') + x + ' ' + (SURFACE + Math.sin(x * 0.06 + s * 2) * 1.6 + Math.sin(x * 0.11 - s * 1.3)).toFixed(2);
        surface.setAttribute('d', surf);
        water.setAttribute('d', surf + 'L360 230L40 230Z');
        rays.setAttribute('opacity', (0.6 + 0.4 * Math.sin(s * 0.8)).toFixed(2));

        weeds.forEach(function (w) {
          var p = 'M' + w.x + ' ' + (FLOOR + 4), segs = 4;
          for (var k = 1; k <= segs; k++) {
            var u = k / segs, yy = FLOOR + 4 - w.h * u;
            var sway = Math.sin(s * 1.4 - u * 2.2 + w.ph) * 9 * u;
            var cx = w.x + Math.sin(s * 1.4 - (u - 0.12) * 2.2 + w.ph) * 9 * (u - 0.12) + (k % 2 ? 5 : -5);
            p += 'Q' + cx.toFixed(1) + ' ' + (yy + w.h / segs / 2).toFixed(1) + ' ' + (w.x + sway).toFixed(1) + ' ' + yy.toFixed(1);
          }
          w.el.setAttribute('d', p);
        });

        fish.forEach(function (f) {
          // Turn at the glass, and sometimes on a whim.
          var edge = 20 * f.s;
          if ((f.dir > 0 && f.x > R - edge) || (f.dir < 0 && f.x < L + edge) || Math.random() < dt * 0.00006) f.dir = -f.dir;
          f.face += (f.dir - f.face) * Math.min(1, dt * 0.005);
          f.x += f.v * f.face * dt;
          f.wander -= dt;
          if (f.wander < 0) { f.wander = rand(2000, 5000); f.ty = rand(SURFACE + 22, FLOOR - 26); }
          var vy = (f.ty - f.y) * Math.min(1, dt * 0.0008);
          f.y += vy;
          var bob = Math.sin(t * 0.002 + f.ph) * 2;
          var tilt = Math.max(-14, Math.min(14, vy / Math.max(1, dt) * 600)) * f.face;
          f.el.setAttribute('transform', 'translate(' + f.x.toFixed(1) + ' ' + (f.y + bob).toFixed(1) + ') rotate(' + tilt.toFixed(1) + ') scale(' + (f.face * f.s).toFixed(3) + ' ' + f.s.toFixed(3) + ')');
          f.tail.setAttribute('transform', 'translate(-12 0) rotate(' + (Math.sin(t * 0.012 + f.ph) * 16).toFixed(1) + ')');
        });

        bubbles.forEach(function (bb) {
          if (bb.y > 900) {
            bb.wait -= dt;
            if (bb.wait > 0) { bb.el.setAttribute('r', 0); return; }
            spawnBubble(bb);
          }
          bb.y -= bb.v * dt;
          bb.x = bb.x0 + Math.sin(bb.y * 0.08 + bb.ph) * 2.5;
          if (bb.y < SURFACE + 2) { bb.y = 999; bb.wait = rand(100, 900); bb.el.setAttribute('r', 0); return; }
          bb.el.setAttribute('cx', bb.x.toFixed(1));
          bb.el.setAttribute('cy', bb.y.toFixed(1));
          bb.el.setAttribute('r', bb.r.toFixed(2));
        });
      }

      if (reduced()) {
        bubbles.forEach(function (bb, i) { spawnBubble(bb); bb.y = FLOOR - 10 - i * 9; });
        frame(0, 1500);
        return;
      }

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

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

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-fish-tank></div>
<h2>Nothing here but us fish</h2>

<!-- a busier tank: -->
<div data-fish-tank data-fish="9"></div>