Breathing Guide
A soft circle slowly grows as you breathe in, holds, and shrinks as you breathe out. A word and a countdown in the middle tell you what to do, and a ring round the edge fills through each phase.
How to use it
- Paste the snippet just before
</head>. - Add an empty
<div data-breathing-guide>where the guide should go. It fills the element with an SVG.
Options
Add any of these attributes to the same element.
data-inhale | Seconds to breathe in. Default 4. |
data-hold | Seconds to hold when full. 0 skips it. Default 2. |
data-exhale | Seconds to breathe out. Default 6. |
data-rest | Seconds to rest when empty. 0 skips it. Default 0. |
data-color | Colour of the circle and ring. Default #7c3aed. |
data-labels | Four comma-separated words for in, hold, out and rest. Default Breathe in,Hold,Breathe out,Rest. |
data-count="false" | No countdown under the word. |
It is 8:5 on a transparent background by default. It pauses when off screen. With reduced motion the circle holds still but the words and countdown still guide you.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Breathing Guide: a soft circle slowly grows as you breathe in, holds,
// and shrinks as you breathe out, with a label and a count in the middle
// and a ring round the edge that fills through each phase. Drawn as SVG
// that fills the element, and paused when off screen.
// Usage: <div data-breathing-guide></div>
// It is 8:5 on a transparent background by default.
// Optional: data-inhale (seconds, default 4), data-hold (seconds held
// full, default 2), data-exhale (seconds, default 6), data-rest (seconds
// held empty, default 0), data-color (default #7c3aed),
// data-labels (four comma-separated words, default
// "Breathe in,Hold,Breathe out,Rest"), data-count="false" (no countdown).
var DEFAULTS = { inhale: 4, hold: 2, exhale: 6, rest: 0, color: '#7c3aed', labels: ['Breathe in', 'Hold', 'Breathe out', 'Rest'] };
var CX = 200, CY = 125, RMIN = 46, RMAX = 96, RING = 108;
var uid = 0;
var css =
'[data-breathing-guide]{display:block;position:relative;overflow:hidden;width:100%;aspect-ratio:8/5}' +
'.brg-svg{position:absolute;inset:0;width:100%;height:100%;display:block}' +
'.brg-label,.brg-count{font-family:system-ui,-apple-system,"Segoe UI",sans-serif;text-anchor:middle}' +
'.brg-label{font-weight:600;font-size:17px;letter-spacing:.3px}' +
'.brg-count{font-weight:300;font-size:13px}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function num(v, def) { var x = parseFloat(v); return isNaN(x) || x < 0 ? def : x; }
function mix(hex, amt) {
// amt > 0 mixes towards white, < 0 towards black.
var h = hex.replace('#', '');
if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
var out = '#';
for (var i = 0; i < 3; i++) {
var c = parseInt(h.substr(i * 2, 2), 16);
c = amt < 0 ? c * (1 + amt) : c + (255 - c) * amt;
out += ('0' + Math.round(c).toString(16)).slice(-2);
}
return out;
}
function ease(u) { return 0.5 - 0.5 * Math.cos(Math.PI * u); }
function build(el) {
var d = el.dataset;
var n = ++uid;
var col = d.color || DEFAULTS.color;
var labels = d.labels ? d.labels.split(',').map(function (s) { return s.trim(); }) : DEFAULTS.labels;
var showCount = d.count !== 'false';
// Phases: [label index, seconds, radius from (0..1), radius to].
var phases = [
[0, num(d.inhale, DEFAULTS.inhale), 0, 1],
[1, num(d.hold, DEFAULTS.hold), 1, 1],
[2, num(d.exhale, DEFAULTS.exhale), 1, 0],
[3, num(d.rest, DEFAULTS.rest), 0, 0]
].filter(function (p) { return p[1] > 0; });
if (!phases.length) phases = [[0, 4, 0, 1], [2, 4, 1, 0]];
var total = phases.reduce(function (s, p) { return s + p[1]; }, 0) * 1000;
if (!el.hasAttribute('aria-label')) el.setAttribute('aria-label', 'A circle that grows and shrinks to guide slow breathing');
if (!el.hasAttribute('role')) el.setAttribute('role', 'img');
var id = 'brg' + n;
var circ = 2 * Math.PI * RING;
var ink = mix(col, -0.45);
el.innerHTML =
'<svg class="brg-svg" viewBox="0 0 400 250" preserveAspectRatio="xMidYMid meet" aria-hidden="true" focusable="false">' +
'<defs>' +
'<radialGradient id="' + id + '-fill" cx=".42" cy=".38" r=".7"><stop offset="0" stop-color="' + mix(col, 0.82) + '"/><stop offset=".6" stop-color="' + mix(col, 0.55) + '"/><stop offset="1" stop-color="' + mix(col, 0.3) + '"/></radialGradient>' +
'<radialGradient id="' + id + '-halo"><stop offset=".55" stop-color="' + col + '" stop-opacity=".22"/><stop offset="1" stop-color="' + col + '" stop-opacity="0"/></radialGradient>' +
'</defs>' +
'<circle cx="' + CX + '" cy="' + CY + '" r="' + RING + '" fill="none" stroke="' + col + '" stroke-opacity=".14" stroke-width="3"/>' +
'<circle cx="' + CX + '" cy="' + CY + '" r="' + RMAX + '" fill="none" stroke="' + col + '" stroke-opacity=".25" stroke-width="1" stroke-dasharray="2 5"/>' +
'<circle cx="' + CX + '" cy="' + CY + '" r="' + RMIN + '" fill="none" stroke="' + col + '" stroke-opacity=".2" stroke-width="1" stroke-dasharray="2 5"/>' +
'<circle class="brg-arc" cx="' + CX + '" cy="' + CY + '" r="' + RING + '" fill="none" stroke="' + col + '" stroke-width="3" stroke-linecap="round" stroke-dasharray="0 ' + circ.toFixed(1) + '" transform="rotate(-90 ' + CX + ' ' + CY + ')"/>' +
'<circle class="brg-dot" cx="' + CX + '" cy="' + (CY - RING) + '" r="5" fill="' + col + '"/>' +
'<circle class="brg-halo" cx="' + CX + '" cy="' + CY + '" r="' + RMIN + '" fill="url(#' + id + '-halo)"/>' +
'<circle class="brg-ripple" cx="' + CX + '" cy="' + CY + '" r="' + RMIN + '" fill="none" stroke="' + col + '" stroke-width="1.5" opacity="0"/>' +
'<circle class="brg-ball" cx="' + CX + '" cy="' + CY + '" r="' + RMIN + '" fill="url(#' + id + '-fill)"/>' +
'<text class="brg-label" x="' + CX + '" y="' + (showCount ? CY + 1 : CY + 6) + '" fill="' + ink + '"></text>' +
(showCount ? '<text class="brg-count" x="' + CX + '" y="' + (CY + 20) + '" fill="' + ink + '" fill-opacity=".7"></text>' : '') +
'</svg>';
var svg = el.firstChild;
var ball = svg.querySelector('.brg-ball');
var halo = svg.querySelector('.brg-halo');
var ripple = svg.querySelector('.brg-ripple');
var arc = svg.querySelector('.brg-arc');
var dot = svg.querySelector('.brg-dot');
var label = svg.querySelector('.brg-label');
var count = svg.querySelector('.brg-count');
var lastLabel = null, lastCount = null;
var still = reduced();
function frame(t) {
t = ((t % total) + total) % total;
var i = 0, start = 0;
while (t >= start + phases[i][1] * 1000) { start += phases[i][1] * 1000; i++; }
var ph = phases[i], dur = ph[1] * 1000, local = t - start, u = local / dur;
var k = still ? 0.5 : ph[2] + (ph[3] - ph[2]) * ease(u);
var r = RMIN + (RMAX - RMIN) * k;
// A slow shimmer while holding keeps it feeling alive.
if (!still && ph[2] === ph[3]) r += Math.sin(local / 1000 * Math.PI * 1.2) * 1.2;
ball.setAttribute('r', r.toFixed(2));
halo.setAttribute('r', (r * 1.28).toFixed(2));
// The ring fills through the phase, the dot leads it round.
if (!still) {
arc.setAttribute('stroke-dasharray', (circ * u).toFixed(1) + ' ' + circ.toFixed(1));
var a = -Math.PI / 2 + u * Math.PI * 2;
dot.setAttribute('cx', (CX + Math.cos(a) * RING).toFixed(1));
dot.setAttribute('cy', (CY + Math.sin(a) * RING).toFixed(1));
}
// A ripple spreads out as each breath out begins.
if (!still && ph[0] === 2 && u < 0.6) {
var w = u / 0.6;
ripple.setAttribute('r', (RMAX + 14 * w).toFixed(1));
ripple.setAttribute('opacity', (0.45 * (1 - w)).toFixed(3));
} else ripple.setAttribute('opacity', '0');
var text = labels[ph[0]] || '';
if (text !== lastLabel) { label.textContent = text; lastLabel = text; }
var fade = Math.min(1, local / 350);
label.setAttribute('opacity', fade.toFixed(2));
if (count) {
var c = String(Math.ceil((dur - local) / 1000));
if (c !== lastCount) { count.textContent = c; lastCount = c; }
count.setAttribute('opacity', fade.toFixed(2));
}
}
var t = 0, last = 0;
var timer = anime.createTimer({
loop: true,
duration: 1000,
autoplay: false,
onUpdate: function () {
var now = performance.now();
t += last ? Math.min(100, now - last) : 16;
last = now;
frame(t);
}
});
frame(still ? 1 : 0);
// With reduced motion the circle holds still but the words still guide you.
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
}).observe(el);
}
function init() {
document.querySelectorAll('[data-breathing-guide]').forEach(function (el) {
if (el._brg) return; // idempotent
el._brg = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-breathing-guide></div>
<!-- box breathing, four seconds each: -->
<div data-breathing-guide data-inhale="4" data-hold="4" data-exhale="4" data-rest="4"></div>