← Loaders

Coffee Cup Fill

A cup fills with coffee, its surface rippling under a line of crema, while wisps of steam curl up from it, growing stronger as it fills. On its own it fills, holds, empties and fills again. Give it a data-value and it becomes a progress meter that rises to match. Size it with font-size and colour the cup with color.

How to use it

  1. Paste the snippet just before </head>.
  2. Add an empty <div data-coffee-cup-fill> where the loader should appear. It fills and empties on a loop.
  3. Or, to show real progress, add data-value (0 to 100) and update it from your code: cup.dataset.value = 72.

Options

Add any of these attributes to the same element.

data-valueTurn it into a progress meter filled to this percentage. Change it and the coffee rises to match.
data-durationTime to fill the cup when looping. Default 2600 ms.
data-coffeeCoffee colour. Default #6f4e37.
data-cremaColour of the foam line on top. Default #c8a27a.
data-steam="false"No steam.

It’s 3em across. It’s a progressbar for screen readers, with aria-valuenow kept up to date when you use data-value. It only moves while it’s on screen. With reduced motion the cup sits still, nearly full (or at its value).

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Coffee Cup Fill: a cup fills with coffee, its surface rippling, while
    // wisps of steam curl up from it, growing stronger as it fills. On its own
    // it fills, holds, empties and fills again. Give it a data-value and it
    // becomes a progress meter that rises to match.
    // Sized with font-size (3em across), coloured with color (the cup).
    // It only moves while it's on screen.
    // Usage: <div data-coffee-cup-fill aria-label="Brewing"></div>
    //    or: <div data-coffee-cup-fill data-value="40" aria-label="Uploading"></div>
    //        (then set data-value, 0 to 100, from your code as things progress)
    // Optional: data-duration (ms to fill the cup when looping, default 2600),
    // data-coffee (coffee colour, default #6f4e37), data-crema (colour of the
    // foam line, default #c8a27a), data-steam="false" (no steam).
    var DEFAULTS = { duration: 2600, coffee: '#6f4e37', crema: '#c8a27a' };
    var TOP = 20, BOTTOM = 41; // coffee level when full and empty (view box units)

    var css =
      '.ccf{display:inline-block;width:3em;height:3em;vertical-align:middle}' +
      '.ccf svg{display:block;width:100%;height:100%;overflow:visible}' +
      '.ccf-cup{fill:none;stroke:currentColor;stroke-width:2.4;stroke-linecap:round;stroke-linejoin:round}' +
      '.ccf-in{fill:currentColor;opacity:.08}' +
      '.ccf-steam{fill:none;stroke:currentColor;stroke-width:1.5;stroke-linecap:round;opacity:0}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    var NS = 'http://www.w3.org/2000/svg';
    var uid = 0;
    var CUP = 'M8,17L10,37Q10.6,41 15,41L27,41Q31.4,41 32,37L34,17';

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

    function make(parent, tag, attrs) {
      var n = document.createElementNS(NS, tag);
      for (var k in attrs) n.setAttribute(k, attrs[k]);
      parent.appendChild(n);
      return n;
    }

    function inOut(v) { return v < 0.5 ? 2 * v * v : 1 - Math.pow(-2 * v + 2, 2) / 2; }
    function clampV(v) { return Math.max(0, Math.min(100, isNaN(v) ? 0 : v)); }

    function build(el) {
      var d = el.dataset;
      var id = 'ccf-' + (++uid);
      var meter = d.value != null && d.value !== '';
      el.classList.add('ccf');
      el.setAttribute('role', 'progressbar');
      if (!el.getAttribute('aria-label')) el.setAttribute('aria-label', 'Loading');
      if (meter) { el.setAttribute('aria-valuemin', '0'); el.setAttribute('aria-valuemax', '100'); }
      el.textContent = '';

      var svg = make(el, 'svg', { viewBox: '0 0 48 48', 'aria-hidden': 'true' });
      var clip = make(make(svg, 'defs', {}), 'clipPath', { id: id + '-c' });
      make(clip, 'path', { d: CUP + 'Z' });
      var steam = [];
      if (d.steam !== 'false') {
        [15, 21, 27].forEach(function (x) { steam.push({ x: x, el: make(svg, 'path', { class: 'ccf-steam' }) }); });
      }
      make(svg, 'path', { class: 'ccf-in', d: CUP + 'Z' });
      var g = make(svg, 'g', { 'clip-path': 'url(#' + id + '-c)' });
      var liquid = make(g, 'path', { fill: d.coffee || DEFAULTS.coffee });
      var crema = make(g, 'path', { fill: 'none', stroke: d.crema || DEFAULTS.crema, 'stroke-width': '1.6' });
      make(svg, 'path', { class: 'ccf-cup', d: CUP });
      make(svg, 'path', { class: 'ccf-cup', d: 'M33.5,21.5C40.5,20.5 40.5,32.5 32.4,32' });
      make(svg, 'path', { class: 'ccf-cup', d: 'M4,45L40,45' });

      function surface(y, t, amp) {
        var s = '';
        for (var x = 6; x <= 36; x += 2) {
          var yy = y + amp * Math.sin(x / 4.5 + t / 260) + amp * 0.5 * Math.sin(x / 2.3 - t / 410);
          s += (x === 6 ? 'M' : 'L') + x + ',' + yy.toFixed(2);
        }
        return s;
      }

      // f: how full, 0 to 1; t: time in ms (for the ripples and steam).
      function draw(f, t) {
        var y = BOTTOM + 1.5 - (BOTTOM + 1.5 - TOP) * f;
        var amp = f > 0.02 ? 0.45 : 0;
        var top = surface(y, t, amp);
        liquid.setAttribute('d', top + 'L36,48L6,48Z');
        crema.setAttribute('d', top);
        crema.style.opacity = f > 0.04 ? '1' : '0';
        var strength = Math.min(1, f * 1.4);
        steam.forEach(function (s, i) {
          var u = ((t / 2200 + i * 0.37) % 1 + 1) % 1;
          var base = 14 - u * 7, p = '';
          for (var k = 0; k <= 16; k++) {
            var yy = base - k * 0.45;
            var xx = s.x + Math.sin(k * 0.36 + t / 320 + i * 2) * (0.5 + k * 0.08);
            p += (k ? 'L' : 'M') + xx.toFixed(2) + ',' + yy.toFixed(2);
          }
          s.el.setAttribute('d', p);
          s.el.style.opacity = (Math.sin(u * Math.PI) * 0.55 * strength).toFixed(3);
        });
      }

      var level = meter ? clampV(+d.value) / 100 : 0;
      if (meter) el.setAttribute('aria-valuenow', String(Math.round(level * 100)));
      if (meter) {
        new MutationObserver(function () {
          el.setAttribute('aria-valuenow', String(Math.round(clampV(+d.value))));
          if (reduced()) draw(clampV(+d.value) / 100, 0);
        }).observe(el, { attributes: true, attributeFilter: ['data-value'] });
      }
      if (reduced()) { draw(meter ? level : 0.8, 0); return null; }
      draw(meter ? level : 0, 0);

      var dur = Math.max(400, +d.duration || DEFAULTS.duration);
      var hold = 1400, drain = 700, rest = 400, cycle = dur + hold + drain + rest;
      var t = 0, last = 0;
      return anime.createTimer({
        loop: true,
        duration: 1000,
        autoplay: false,
        onUpdate: function () {
          var now = performance.now();
          var dt = last ? Math.min(50, now - last) : 16;
          t += dt;
          last = now;
          var f;
          if (meter) {
            // Ease towards the current value.
            level += (clampV(+d.value) / 100 - level) * Math.min(1, dt / 220);
            f = level;
          } else {
            var c = t % cycle;
            f = c < dur ? inOut(c / dur) : c < dur + hold ? 1 : c < dur + hold + drain ? 1 - inOut((c - dur - hold) / drain) : 0;
          }
          draw(f, t);
        },
        onPause: function () { last = 0; },
      });
    }

    function init() {
      document.querySelectorAll('[data-coffee-cup-fill]').forEach(function (el) {
        if (el._ccf) return;
        el._ccf = true;
        var timer = build(el);
        if (!timer) return;
        new IntersectionObserver(function (entries) {
          entries[0].isIntersecting ? timer.play() : timer.pause();
        }).observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-coffee-cup-fill style="font-size: 24px; color: #6a0dad" aria-label="Brewing"></div>

<!-- as a progress meter: -->
<div data-coffee-cup-fill data-value="40" aria-label="Uploading"></div>
<script>
  document.querySelector("[data-coffee-cup-fill]").dataset.value = 100;
</script>