← Numbers

Thermometer Fill

The mercury in a glass thermometer rises to a temperature while the reading beside it counts up to match. It warms from blue through amber to red as it climbs and settles with a tiny overshoot.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-thermometer-fill="23.5" (the temperature) to an empty element. Size it with font-size; the thermometer is 9em tall.

Options

Add any of these attributes to the same element.

data-min / data-maxThe ends of the scale. Default −10 and 40.
data-unitUnit after the reading. Default °C.
data-values="A|B|C"Several temperatures to move between in turn.
data-holdHow long each temperature stays. Default 2400 ms.
data-loopWith a single temperature: drain and rise again every few seconds.
data-labelSmall text under the reading, e.g. Living room.
data-colorsCold, mild and hot colours. Default #38bdf8,#f59e0b,#ef4444.
data-durationHow long the mercury takes to rise. Default 1600 ms.
1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Thermometer Fill: a glass thermometer whose mercury rises to a
    // temperature while the reading beside it counts up to match. The mercury
    // warms from blue to red as it climbs, and settles with a tiny overshoot.
    // Usage: <div data-thermometer-fill="23.5"></div>
    // Optional: data-min / data-max (the scale, default -10 and 40),
    // data-unit (default °C), data-values (several readings to move between,
    // "A|B|C"), data-hold (ms each reading stays, default 2400), data-loop
    // (with a single value: drain and rise again every few seconds),
    // data-colors (cold, mild and hot colours, default "#38bdf8,#f59e0b,#ef4444"),
    // data-label (small text under the reading), data-duration (ms, default 1600).
    // Size it with font-size: the thermometer is 9em tall.
    var DEFAULTS = { min: -10, max: 40, unit: '°C', hold: 2400, duration: 1600, colors: '#38bdf8,#f59e0b,#ef4444' };
    var NS = 'http://www.w3.org/2000/svg';
    // Tube geometry in the 60 × 200 viewBox: mercury runs from BOTTOM up to TOP.
    var TOP = 22, BOTTOM = 150;

    var css =
      '.thf{display:inline-flex;align-items:center;gap:.7em;font-variant-numeric:tabular-nums}' +
      '.thf svg{flex:none;width:2.9em;height:9em;display:block;overflow:visible}' +
      '.thf-glass{fill:#fff;stroke:#d8d2e2;stroke-width:1.5}' +
      '.thf-tick{stroke:currentColor;stroke-width:1;opacity:.35}' +
      '.thf-tick.thf-major{stroke-width:1.4;opacity:.55}' +
      '.thf-scale{font:600 7.5px system-ui,sans-serif;fill:currentColor;opacity:.55}' +
      '.thf-read{display:grid;gap:.15em;text-align:left}' +
      '.thf-num{font-size:2.6em;font-weight:800;letter-spacing:-.04em;line-height:1;white-space:nowrap}' +
      '.thf-num small{font-size:.5em;font-weight:700;letter-spacing:0;margin-left:.05em;vertical-align:.7em}' +
      '.thf-label{font-size:.8em;font-weight:600;opacity:.6}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

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

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

    function hex(c) {
      var h = c.trim().replace('#', '');
      if (h.length === 3) h = h.replace(/./g, '$&$&');
      var n = parseInt(h, 16);
      return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
    }
    // Colour at share p (0 cold … 1 hot) between the three stops.
    function mix(stops, p) {
      p = Math.max(0, Math.min(1, p));
      var seg = p < 0.5 ? 0 : 1, t = p < 0.5 ? p * 2 : (p - 0.5) * 2;
      var a = stops[seg], b = stops[seg + 1];
      return 'rgb(' + a.map(function (v, i) { return Math.round(v + (b[i] - v) * t); }).join(',') + ')';
    }

    // A tidy step for the scale: 5, 10, 20… so there are 4–8 labelled marks.
    function niceStep(range) {
      var steps = [1, 2, 5, 10, 20, 25, 50, 100, 200, 250, 500, 1000];
      for (var i = 0; i < steps.length; i++) if (range / steps[i] <= 8) return steps[i];
      return range / 5;
    }

    function setup(el) {
      var d = el.dataset;
      var first = el.getAttribute('data-thermometer-fill');
      var values = (d.values ? d.values.split('|') : [first || '20']).map(parseFloat);
      var th = {
        el: el, values: values, index: 0,
        min: d.min !== undefined ? +d.min : DEFAULTS.min, max: d.max !== undefined ? +d.max : DEFAULTS.max,
        unit: d.unit !== undefined ? d.unit : DEFAULTS.unit, hold: +d.hold || DEFAULTS.hold,
        duration: +d.duration || DEFAULTS.duration,
        colors: (d.colors || DEFAULTS.colors).split(',').map(hex),
        loop: d.loop !== undefined && d.loop !== 'false', still: reduced(), visible: false, started: false,
        timer: 0, anim: null, value: 0
      };
      // Show decimals if any reading has them.
      th.decimals = values.some(function (v) { return v % 1 !== 0; }) ? 1 : 0;
      el.classList.add('thf');
      el.textContent = '';
      var s = svg('svg', { viewBox: '0 0 64 200', 'aria-hidden': 'true' }, el);
      var id = 'thf-clip-' + Math.random().toString(36).slice(2, 8);
      var defs = svg('defs', {}, s);
      var clip = svg('clipPath', { id: id }, defs);
      svg('rect', { x: 25, y: TOP - 8, width: 10, height: 160, rx: 5 }, clip);
      svg('circle', { cx: 30, cy: 170, r: 14.5 }, clip);
      // Glass: tube and bulb.
      svg('path', { 'class': 'thf-glass', d: 'M22 153.9V18a8 8 0 0 1 16 0v135.9a18 18 0 1 1-16 0z' }, s);
      var g = svg('g', { 'clip-path': 'url(#' + id + ')' }, s);
      th.bulb = svg('circle', { cx: 30, cy: 170, r: 13 }, g);
      th.col = svg('rect', { x: 26.5, width: 7, rx: 3.5 }, g);
      svg('rect', { x: 27.6, y: TOP, width: 1.6, height: 140, rx: 0.8, fill: '#fff', opacity: 0.35 }, g);
      svg('circle', { cx: 24.5, cy: 164, r: 3.5, fill: '#fff', opacity: 0.4 }, g);
      // Scale marks to the right of the tube.
      var step = niceStep(th.max - th.min), minor = step / 2;
      for (var v = Math.ceil(th.min / minor) * minor; v <= th.max + 1e-9; v += minor) {
        var y = yOf(th, v), major = Math.abs(v / step - Math.round(v / step)) < 1e-6;
        svg('line', { x1: 41, x2: major ? 48 : 45, y1: y, y2: y, 'class': 'thf-tick' + (major ? ' thf-major' : '') }, s);
        if (major) svg('text', { x: 50, y: y + 2.6, 'class': 'thf-scale' }, s).textContent = Math.round(v);
      }
      var read = document.createElement('div');
      read.className = 'thf-read';
      read.innerHTML = '<div class="thf-num"></div>' + (d.label ? '<div class="thf-label"></div>' : '');
      el.appendChild(read);
      if (d.label) read.querySelector('.thf-label').textContent = d.label;
      th.num = read.querySelector('.thf-num');
      th.num.setAttribute('aria-hidden', 'true');
      el.setAttribute('role', 'img');
      draw(th, th.still ? values[0] : th.min);
      label(th, values[0]);
      return th;
    }

    function yOf(th, v) {
      var p = (v - th.min) / (th.max - th.min);
      return BOTTOM - Math.max(0, Math.min(1.04, p)) * (BOTTOM - TOP);
    }

    function label(th, v) { th.el.setAttribute('aria-label', 'Temperature ' + v + ' ' + th.unit.replace('°', 'degrees ').trim()); }

    function draw(th, v) {
      th.value = v;
      var y = yOf(th, v), col = mix(th.colors, (v - th.min) / (th.max - th.min));
      th.col.setAttribute('y', y.toFixed(2));
      th.col.setAttribute('height', (176 - y).toFixed(2));
      th.col.setAttribute('fill', col);
      th.bulb.setAttribute('fill', col);
      th.num.style.color = col;
      th.num.innerHTML = v.toFixed(th.decimals).replace('-', '&minus;') + '<small></small>';
      th.num.lastChild.textContent = th.unit;
    }

    function rise(th, to, done) {
      if (th.anim) th.anim.pause();
      var st = { v: th.value };
      th.anim = anime.animate(st, {
        v: to, duration: th.duration, ease: 'outBack(1.2)',
        onUpdate: function () { draw(th, st.v); },
        onComplete: function () { draw(th, to); if (done) done(); }
      });
      label(th, to);
    }

    function step(th) {
      clearTimeout(th.timer);
      if (!th.visible) return;
      var to = th.values[th.index];
      th.index = (th.index + 1) % th.values.length;
      rise(th, to, function () {
        if (th.values.length > 1) th.timer = setTimeout(function () { step(th); }, th.hold);
        else if (th.loop) th.timer = setTimeout(function () {
          var st = { v: th.value };
          th.anim = anime.animate(st, {
            v: th.min, duration: 900, ease: 'inOut(2)', onUpdate: function () { draw(th, st.v); },
            onComplete: function () { th.timer = setTimeout(function () { step(th); }, 400); }
          });
        }, th.hold);
      });
    }

    function init() {
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          var th = e.target._thf;
          th.visible = e.isIntersecting;
          if (th.visible) {
            if (!th.started || th.values.length > 1 || th.loop) step(th);
            th.started = true;
          } else clearTimeout(th.timer);
        });
      }, { threshold: 0.3 });
      document.querySelectorAll('[data-thermometer-fill]').forEach(function (el) {
        if (el._thf) return;
        var th = (el._thf = setup(el));
        if (!th.still) io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-thermometer-fill="23.5" data-label="Living room" style="font-size: 20px"></div>

<!-- Fahrenheit: -->
<div data-thermometer-fill="72" data-unit="°F" data-min="0" data-max="110"></div>