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
- Paste the snippet just before
</head>. - Add
data-thermometer-fill="23.5"(the temperature) to an empty element. Size it withfont-size; the thermometer is 9em tall.
Options
Add any of these attributes to the same element.
data-min / data-max | The ends of the scale. Default −10 and 40. |
data-unit | Unit after the reading. Default °C. |
data-values="A|B|C" | Several temperatures to move between in turn. |
data-hold | How long each temperature stays. Default 2400 ms. |
data-loop | With a single temperature: drain and rise again every few seconds. |
data-label | Small text under the reading, e.g. Living room. |
data-colors | Cold, mild and hot colours. Default #38bdf8,#f59e0b,#ef4444. |
data-duration | How long the mercury takes to rise. Default 1600 ms. |
<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('-', '−') + '<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><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>